401 means authenticate, 403 means you're authenticated but not allowed. Getting this distinction right in JWT-based APIs makes debugging far easier.
The distinction, precisely
- **401 Unauthorized** means the request lacks valid authentication — the server doesn't know who you are, or doesn't trust your claim of who you are. Despite the name, it's really about **authentication**, not authorization.
- **403 Forbidden** means the server knows exactly who you are, and the answer is still no — you're **authenticated but not authorized** for this particular action or resource.
Mixing these up doesn't just annoy API consumers, it actively misleads them about what to do next: retrying with a fresh token fixes a 401, but does nothing for a 403.
Mapping JWT failures to status codes
- Missing `Authorization` header entirely → **401**
- Malformed token (not valid JWT structure) → **401**
- Signature verification fails → **401**
- Token expired (`exp` in the past) → **401**
- Token `aud` doesn't match this API → **401** (the token isn't valid *for you*, which is an authentication-layer problem)
- Token is valid, but the user's role/scope doesn't permit this action → **403**
- Token is valid, but the requested resource belongs to a different user/tenant → **403**
The general rule: if the problem can be fixed by presenting a different or refreshed token, it's a 401. If a different token from the same user wouldn't help — the user genuinely isn't allowed to do this — it's a 403.
A subtlety: resource existence and 403 vs 404
Some APIs intentionally return 404 instead of 403 for resources a user isn't allowed to see, to avoid leaking that the resource exists at all (common for private repos, documents, etc.). This is a legitimate design choice but is a deliberate exception, not a substitute for correct 401/403 usage elsewhere.
Client-side handling patterns
- On **401**, attempt a silent token refresh once; if that also fails, redirect to login.
- On **403**, do not attempt to refresh or retry automatically — show the user a clear "you don't have permission" message, since retrying with the same credentials will always fail the same way.
- Avoid silently swallowing either status code — both are meaningful signals for debugging and for user-facing messaging.
A common implementation mistake
A frequent bug in JWT middleware is returning 403 for every failure that occurs in the auth layer, including expired or malformed tokens, because it's the more "restrictive-sounding" code. This breaks client refresh logic, since clients typically only attempt a token refresh on 401. Keep the layers cleanly separated: your authentication middleware (verifying the token itself) should only ever emit 401; your authorization logic (checking roles, ownership, scopes) after that point emits 403.
Example middleware structure
- **Authentication middleware**: parses the `Authorization` header, verifies signature, checks `exp`/`nbf`/`aud`/`iss`. Any failure here → 401.
- **Authorization middleware/guard**: runs after authentication succeeds, checks the user's role or scope against the requested action. Any failure here → 403.
Checklist
- Return 401 for anything wrong with the token itself: missing, malformed, expired, bad signature, wrong audience.
- Return 403 only once you've established identity and the answer is still "not allowed."
- Keep authentication and authorization as clearly separate middleware layers.
- Make sure your client only attempts token refresh on 401, never on 403.
- Document your API's 401/403 semantics explicitly — this is a frequent source of integration bugs for API consumers.