401 Unauthorized
Client error response defined by RFC 9110 §15.5.2.
Last updated July 27, 2026
Common causes at a glance
- No Authorization header sent at all, often a proxy stripping it on redirect
- Access token expired and the refresh flow did not run
- Token signed by a different issuer or key than the verifier expects
- Clock skew making a freshly issued JWT fail the nbf or exp check
Reproduce it
curl -i https://api.example.com/v1/meWhat 401 Unauthorized means
The name is a historical misnomer: 401 is about authentication, not
authorization. Read it as “unauthenticated”. Missing token, expired token, malformed
Authorization header, signature that does not verify — all 401. Valid token
belonging to a user who lacks the role — 403.
Getting this wrong has consequences beyond pedantry. Clients branch on it: a 401 is the signal to run the refresh-token flow and retry once, while a 403 must not trigger a retry. Return 401 for a permission failure and clients hammer your token endpoint in a loop; return 403 for an expired token and sessions never transparently renew.
What the spec says
RFC 9110 §15.5.2 is unusually firm: a server
generating 401 MUST send a WWW-Authenticate header field
containing at least one challenge. The header and the challenge grammar are defined in
§11.6.1 and the authentication framework in
§11. For OAuth bearer tokens the challenge parameters come from
RFC 6750 §3: invalid_request,
invalid_token, and insufficient_scope, where the last belongs
with 403. Most JSON APIs violate the MUST and send a bare 401 with a JSON body, which
works with hand-written clients and breaks generic tooling.
What actually causes it
Two causes dominate in production. The first is header stripping: curl
drops Authorization when following a redirect to a different host unless you
pass --location-trusted, and many load balancers do the same on purpose. An
HTTP-to-HTTPS or trailing-slash redirect is enough to lose the credential, which is why
the same request passes with the canonical URL and 401s without it. The second is clock
skew on the verifier — a JWT with an nbf a few seconds in the future fails
validation on a host whose NTP has drifted, producing intermittent 401s on a subset of
pods that look random until you correlate by instance. Also common: an API gateway
validating the token itself and returning its own 401 with an unfamiliar body.
How to debug it
Read the challenge the server sends back before anything else:
curl -sD - -o /dev/null https://api.example.com/v1/me | grep -i www-authenticate
A well-behaved server names the scheme and, for bearer tokens, an
error="invalid_token" or error_description parameter giving the
actual failure. If WWW-Authenticate is missing entirely, the 401 came from
application code that ignored the MUST, and you are reduced to reading the body.
Next, take redirects out of the picture with curl -i and no -L:
a 301 before the 401 means the credential was probably dropped at that hop. For JWTs,
decode the payload and compare exp and iss against the
verifier’s expectations and against date -u on the failing host.
Working examples
curl
# Read the challenge — it usually names the exact failure.
curl -sD - -o /dev/null https://api.example.com/v1/me | grep -i www-authenticate
# Authorization is dropped across a cross-host redirect unless you opt in.
curl -i -H "Authorization: Bearer $TOKEN" https://api.example.com/v1/me
curl -i --location-trusted -H "Authorization: Bearer $TOKEN" http://api.example.com/v1/me
# Compare against a known-good credential to isolate token vs endpoint.
curl -i -u 'user:pass' https://api.example.com/v1/me
Python (requests)
import requests
resp = requests.get("https://api.example.com/v1/me", timeout=10)
print(resp.status_code)
print(resp.headers.get("WWW-Authenticate")) # RFC 6750 error= lives here
# requests also drops the Authorization header on cross-host redirects.
s = requests.Session()
s.headers["Authorization"] = f"Bearer {token}"
r = s.get("https://api.example.com/v1/me", allow_redirects=False)
print(r.status_code, r.headers.get("Location"))
# Compare the server's clock before blaming the token.
print(resp.headers.get("Date"))
Node (fetch)
const res = await fetch("https://api.example.com/v1/me", {
headers: { Authorization: "Bearer " + token },
redirect: "manual", // see redirects instead of silently losing the header
});
console.log(res.status, res.headers.get("www-authenticate"));
// Decode a JWT payload locally to check exp/iss before re-issuing it.
const payload = token.split(".")[1];
if (payload) {
const claims = JSON.parse(Buffer.from(payload, "base64url").toString());
console.log("exp", new Date(claims.exp * 1000), "now", new Date());
}Reference and tooling
The authoritative list of assigned status codes is theIANA HTTP Status Code Registry. For a searchable copy with the semantics summarised, use theHTTP status code reference on this site. To see exactly what a server is sending, theheader inspector shows the raw status line and response headers, and thecurl builder assembles the request flags for you.
Other 4xx codes
- 400 Bad Request
- 402 Payment Required
- 403 Forbidden
- 404 Not Found
- 405 Method Not Allowed
- 406 Not Acceptable
- 407 Proxy Authentication Required
- 408 Request Timeout
- 409 Conflict
- 410 Gone
- 411 Length Required
- 412 Precondition Failed
- 413 Content Too Large
- 414 URI Too Long
- 415 Unsupported Media Type
- 416 Range Not Satisfiable
- 417 Expectation Failed
- 418 (Unused)
- 419 Page Expired
- 421 Misdirected Request
- 422 Unprocessable Content
- 423 Locked
- 424 Failed Dependency
- 425 Too Early
- 426 Upgrade Required
- 428 Precondition Required
- 429 Too Many Requests
- 431 Request Header Fields Too Large
- 444 No Response
- 451 Unavailable For Legal Reasons
- 494 Request Header Too Large
- 495 SSL Certificate Error
- 496 SSL Certificate Required
- 497 HTTP Request Sent to HTTPS Port
- 499 Client Closed Request