Skip to content
Control Plane Labs

401 Unauthorized

Client error response defined by RFC 9110 §15.5.2.

Written and maintained by Ben Ennis

Last reviewed July 27, 2026 · How we verify this

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/me

What 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.

Why a browser stops asking for the password after the first 401

Browsers handling Basic authentication under RFC 7617 cache the credential you enter against the combination of origin and realm from the WWW-Authenticate challenge, then attach it automatically to every subsequent request in that realm without waiting for another 401. This is convenient for a browsable admin tool and a liability for an API: change the password server-side and already-open tabs keep sending the stale credential until the user closes the browser or you change the realm string, since there is no equivalent of a token refresh for Basic auth. Rotating the realm value forces every client to re-prompt, which is the closest thing to forced logout this scheme has.

When a server offers more than one scheme in WWW-Authenticate, or across multiple headers, clients do not necessarily honour the order you listed them in. Chromium’s own authentication documentation describes scoring challenges by strength, Negotiate above NTLM above Digest above Basic, and picking the strongest one the client supports rather than the server’s preferred first entry; older Windows HTTP stacks are documented to instead take the first non-Basic scheme they recognise. If you intend to steer clients toward a specific scheme, test with the actual client in question rather than assuming header order controls selection.

None of this applies to bearer tokens: there is no browser-native caching for a scheme the platform does not implement natively, which is one reason APIs prefer Authorization: Bearer over Basic for anything beyond quick internal tooling.

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