401 vs 403 — The HTTP Status Code Difference Every Developer Gets Wrong
Updated April 2026
401 Unauthorized means the user is not authenticated — they need to log in. 403 Forbidden means the user is authenticated but not allowed. They are not interchangeable and using the wrong one breaks clients, caches, and monitoring.
The one-line rule
| Code | Meaning | Who are you? |
|---|---|---|
| 401 | Unauthorized | I don't know who you are. Log in. |
| 403 | Forbidden | I know who you are. You can't do this. |
401 — Unauthenticated
Return 401 when the request lacks valid credentials. The user has not logged in, the token is missing, expired, or invalid.
The HTTP spec requires a WWW-Authenticate header on 401 responses telling the client how to authenticate:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token"
Content-Type: application/json
{"error": "authentication_required", "message": "No valid token provided"}
When to return 401:
- No Authorization header present
- JWT is expired
- API key is invalid or revoked
- Session cookie is missing or expired
- OAuth token has been revoked
403 — Forbidden
Return 403 when the user is authenticated but lacks permission. The server knows who they are and the answer is still no.
HTTP/1.1 403 Forbidden
Content-Type: application/json
{"error": "insufficient_permissions", "message": "Admin role required"}
When to return 403:
- User is logged in but tries to access another user's resource
- Free tier user tries to access a paid feature
- Read-only user tries to write
- IP is blocked by a firewall rule
- CORS preflight is rejected
- Rate limit exceeded for a specific endpoint (sometimes 429 is better)
The security case for 404 instead of 403
Returning 403 reveals that the resource exists. An attacker learns they found a valid endpoint — they just can't access it. Returning 404 for unauthorized private resources prevents enumeration. This is a deliberate design choice, not a bug.
# Revealing (403) GET /admin/users/123 → 403 Forbidden # Attacker knows the resource exists # Non-revealing (404) GET /admin/users/123 → 404 Not Found # Attacker learns nothing
What breaks when you mix them up
| Wrong code | What breaks |
|---|---|
| Returning 403 for missing token | OAuth clients won't retry with fresh token — they treat 403 as a permanent permission error |
| Returning 401 for insufficient permissions | Clients loop on auth refresh — they think re-authenticating will fix it |
| Returning 200 for auth errors | Monitoring misses failures, retry logic breaks, caches store error responses |
Framework examples
Express
// 401 — missing or invalid token
app.use((req, res, next) => { if (!req.headers.authorization) { return res.status(401) .set('WWW-Authenticate', 'Bearer realm="api"') .json({ error: 'authentication_required' }) } next()
})
// 403 — valid token but wrong role
app.get('/admin', requireAuth, (req, res) => { if (req.user.role !== 'admin') { return res.status(403).json({ error: 'insufficient_permissions' }) } // ...
})
FastAPI
from fastapi import HTTPException, status
# 401
raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials", headers={"WWW-Authenticate": "Bearer"},
)
# 403
raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions",
)
Scan your API headers → HeadersFixer