Blog
3 min read

401 vs 403: Unauthorized vs Forbidden, Explained

401 means 'we don't know who you are' — log in or send valid credentials. 403 means 'we know who you are, and you're not allowed'. How to tell them apart, the common causes of each, how to fix them as a user or developer, and when to return 404 instead.

Two HTTP status codes cover "you can't have this", and they mean different things:

  • 401 Unauthorized — "I don't know who you are." You're not logged in, or your credentials are missing, wrong or expired.
  • 403 Forbidden — "I know exactly who you are, and you're not allowed."

The names are confusing — 401 is really about authentication, 403 about authorisation. (Authentication vs authorization)

401 Unauthorized

The server needs to identify you and couldn't.

Common causes:

  • Not logged in, or the session cookie wasn't sent (Cookies explained)
  • Missing Authorization header in an API request
  • Expired token — JWTs and access tokens have short lifetimes (What is a JWT?)
  • Wrong or revoked API key
  • Typo in the header format: Bearer missing, or an extra space

Fixes:

  • As a user: log in again.
  • As a developer: check the request in DevTools → Network → Request Headers. Is the Authorization header or cookie actually there? (HTTP headers explained)
  • If cookies aren't sent cross-site, check credentials: 'include' on fetch and the cookie's SameSite/Secure settings.
  • If tokens expire, implement refresh. (Refresh tokens)

A proper 401 response includes a WWW-Authenticate header saying how to authenticate.

403 Forbidden

You're identified, but you don't have permission.

Common causes:

  • A regular user calling an admin-only endpoint (Role-based access control)
  • Trying to change someone else's resource
  • An API key without the needed scope
  • Blocked by a firewall, Cloudflare rule or IP restriction
  • Web server file permissions — Nginx returns 403 when it can't read the files it's supposed to serve (Linux file permissions)
  • No index.html in a folder and directory listing disabled
  • Supabase/Postgres row-level security rejecting the request (Supabase RLS explained)

Fixes:

  • As a user: you need the right role or permission; logging in again won't help.
  • As a developer: check the user's role, the resource's owner, and your permission logic. For Nginx, check file ownership and permissions on the web root.

Side by side

401 Unauthorized 403 Forbidden
Meaning Not authenticated Not authorised
Server knows who you are? No Yes
Will logging in help? Yes No
Typical fix Send valid credentials Get permission, or fix the rules

Should you return 404 instead?

Sometimes. If user A requests /api/orders/1234, which belongs to user B, returning 403 confirms that order 1234 exists. Returning 404 Not Found reveals nothing. Many APIs (including GitHub's) use 404 for resources the user isn't allowed to know about. (IDOR explained)

What matters most is that the request is rejected — the most common security bug in AI-built apps is endpoints that return the data with 200 OK to whoever asks.

Building it right

In your API:

  1. Authenticate on every protected route — no valid user → 401.
  2. Authorise every action — user may not do this → 403 (or 404).
  3. Check ownership in the database query, e.g. WHERE id = $1 AND user_id = $2.
  4. Test both: call each endpoint logged out, and as the wrong user. (How to test an API)

EasySpawn runs your app and API on your own server with logs one click away, so you can see exactly which requests are being rejected and why. See how it works or join the waitlist.

Related: HTTP Status Codes Explained · Authentication vs Authorization · What Is IDOR? · API Authentication Methods

Keep reading