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
Authorizationheader 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:
Bearermissing, or an extra space
Fixes:
- As a user: log in again.
- As a developer: check the request in DevTools → Network → Request Headers. Is the
Authorizationheader or cookie actually there? (HTTP headers explained) - If cookies aren't sent cross-site, check
credentials: 'include'on fetch and the cookie'sSameSite/Securesettings. - 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.htmlin 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:
- Authenticate on every protected route — no valid user → 401.
- Authorise every action — user may not do this → 403 (or 404).
- Check ownership in the database query, e.g.
WHERE id = $1 AND user_id = $2. - 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
"Your Connection Is Not Private" on Your Own Site: Causes and Fixes
When visitors see NET::ERR_CERT_DATE_INVALID, ERR_CERT_COMMON_NAME_INVALID or ERR_CERT_AUTHORITY_INVALID on your site, the SSL certificate is expired, for the wrong name, or incomplete. How to tell which, and how to fix each one.
"new row violates row-level security policy" in Supabase: How to Fix It
Supabase (Postgres) blocked an insert or update because no RLS policy allows it. Why it happens, how to write an INSERT policy with WITH CHECK, the user_id default trick, the hidden SELECT-after-insert problem, storage uploads, and why you must never 'fix' it by disabling RLS.