Skip to content
Control Plane Labs

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

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