A 401 means the request lacked valid authentication credentials, so the server does not know who is asking. The status name says Unauthorized, and that word is a forty-year-old misnomer: it means unauthenticated.
The distinction is not pedantry, it is the fastest route to the fix. A 401 says identify yourself. A 403 says I know who you are and the answer is still no. Confusing them sends people to check permissions when the token was simply expired, or to reissue credentials when the account genuinely lacks access.
The specification is also clear that a 401 must include a WWW-Authenticate header telling the client how to authenticate, and a great many APIs omit it, which removes the one piece of machine-readable guidance the response was supposed to carry.
Table of contents
- 401 against 403, settled
- The header the response should carry
- The causes, in order of frequency
- The redirect trap, which deserves its own paragraph
- Building 401 responses that clients can act on
- How this fits the rest of the stack
- FAQ
401 against 403, settled
One sentence each.
401: no valid credentials were supplied. The server cannot identify you. Supplying valid credentials would change the outcome.
403: credentials were supplied and understood, but this identity is not permitted to do this. Supplying the same credentials again changes nothing.
The practical consequence is what you do next. On a 401, obtain or refresh a credential. On a 403, either change what you are asking for or change the permissions attached to the identity.
There is a third case worth knowing because it is deliberate: an API returning 404 for a resource that exists but which you may not see. This avoids leaking existence through the difference between 403 and 404, and it is good practice for anything where the identifier itself is sensitive. If you are certain a resource exists and receive a 404, an authorisation boundary is a plausible explanation.
The header the response should carry
The specification requires a 401 to include a WWW-Authenticate header naming at least one authentication scheme the client can use.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="The access token expired"
That error parameter is the useful part and it is widely omitted. It distinguishes a token that expired from one that was malformed from one that lacked the necessary scope, and those three have completely different fixes.
If you build APIs, include it. A client receiving error="invalid_token" can refresh automatically; a client receiving a bare 401 has to guess whether refreshing is even worth attempting, and the usual guess is an infinite retry loop.
# What is the server actually telling you?
curl -sI -H "Authorization: Bearer $TOKEN" https://api.example.com/v1/thing | grep -i www-authenticate
The causes, in order of frequency
When a request that worked yesterday returns 401 today, it is nearly always one of these.
- The token expired. Access tokens are commonly short-lived by design. The fix is refreshing, and the bug is usually that the client does not refresh on a 401.
- A malformed Authorization header. The scheme keyword is missing, the case is wrong, or there are two spaces.
Bearerfollowed by one space followed by the token, exactly. - The credential is for a different environment. A staging key against production, or the reverse. Extremely common and invisible until you decode the token.
- The clock is wrong. Token validation checks issued-at and expiry timestamps. A server whose clock has drifted by minutes will reject tokens that are perfectly valid.
- The header was stripped in transit. Some proxies and redirects drop the Authorization header, particularly on a cross-origin redirect. This produces a 401 the client cannot explain because it definitely sent the header.
- The credential was rotated or revoked and something is still using the old one.
For a JWT, decoding the payload settles most of these in seconds. It is base64, not encryption, so no key is needed to read it:
# Decode the payload segment of a JWT.
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
Read exp against the current time, iss and aud against what the server expects, and scope against what the endpoint requires. That is four of the six causes ruled in or out immediately.
The redirect trap, which deserves its own paragraph
This one causes disproportionate confusion because the client is demonstrably sending the credential and the server is demonstrably not receiving it.
When a request is redirected, many HTTP clients drop the Authorization header if the redirect crosses to a different host. This is correct security behaviour, since forwarding a credential to a host you did not intend to authenticate against would be a serious flaw. But it means a redirect from example.com to www.example.com, or from HTTP to HTTPS on a differently-named host, silently strips your authentication.
The symptom is a 401 on a request that carries a perfectly valid token, with the token arriving at neither the original host nor the final one.
# Follow redirects verbosely and watch what happens to the header.
curl -sv -L -H "Authorization: Bearer $TOKEN" https://example.com/api/thing 2>&1 \
| grep -E '^< HTTP|^< [Ll]ocation|^> [Aa]uthorization'
The fix is to call the canonical URL directly rather than relying on a redirect. An API client should never be following a redirect to reach its endpoint in normal operation.
Building 401 responses that clients can act on
If you are on the server side, a few decisions make this error dramatically less expensive for everyone consuming your API.
- Always send WWW-Authenticate, with the scheme and an error parameter distinguishing expired from invalid from insufficient scope.
- Return 401 for authentication and 403 for authorisation, consistently. Mixing them is the single biggest source of wasted debugging time against an unfamiliar API.
- Do not leak why in the body. Telling an unauthenticated caller that the user exists but the password was wrong is an account enumeration vector. The distinction between expired and invalid is fine; the distinction between wrong user and wrong password is not.
- Log the reason server-side with a correlation identifier the client can quote, so support conversations are a lookup rather than a reconstruction.
- Rate limit authentication failures by account as well as by address, since distributed credential stuffing defeats address-based limits by design.
On the client side, the corresponding discipline is to refresh once on a 401 and then stop. A client that retries indefinitely against an expired credential generates an outage-shaped traffic pattern against your own API, which is a memorable way to discover you have no rate limiting.
How this fits the rest of the stack
Authentication problems are usually credential lifecycle problems, and credential lifecycle gets easier when secrets are set per environment in one place and rotated without a code change. On RunxBuild that is environment variables per service applied on the next deploy, and the RunxBuild hosting calculator covers what the surrounding deployment costs, which is the other half of deciding how many environments you can afford to keep properly separated.
Useful related references:
- SSL_connect Error 5: What SSL_ERROR_SYSCALL Actually Means
- The 504 Gateway Timeout Error, in Plain English
- Math Domain Error Explained: Meaning and How to Debug
- Services on RunxBuild
FAQ
What does a 401 error mean?
The request lacked valid authentication credentials, so the server cannot identify who is asking. Despite the status name saying Unauthorized, it means unauthenticated. Supplying or refreshing a valid credential resolves it, which is what distinguishes it from a 403.
What is the difference between 401 and 403?
A 401 means the server does not know who you are and authenticating would change the outcome. A 403 means the server knows who you are and is refusing anyway, so the same credentials will always produce the same result. Fix a 401 with a credential and a 403 with a permission change.
Why do I get a 401 when my token looks correct?
Common causes are an expired token, a malformed Authorization header, a credential from the wrong environment, or clock drift on the server. Decode the JWT payload, which is base64 rather than encrypted, and check the exp, iss, aud and scope claims against what the endpoint expects.
Why does my Authorization header disappear on a redirect?
Most HTTP clients drop the Authorization header when a redirect crosses to a different host, which is correct security behaviour since forwarding a credential to an unintended host would be a serious flaw. Call the canonical URL directly rather than relying on a redirect to reach your endpoint.
Should a 401 response say why authentication failed?
It should distinguish expired from invalid from insufficient scope, using the error parameter of the WWW-Authenticate header, because clients need that to decide whether refreshing will help. It should not reveal whether a specific account exists, since that is an enumeration vector.