401
The request carried no usable authentication credentials — the reason phrase says Unauthorized, but the condition it describes is unauthenticated, and the header naming what would work is mandatory.
The same three digits are three different problems depending on which side of the request you are on. Read the block that describes you.
Why you are seeing it
The classic shape is a request that works in curl or in your API client and 401s from the deployed page, which almost always means the credential never left the browser. `fetch` defaults its `credentials` option to `same-origin`, so a cross-origin call sends no cookies at all unless you ask for them, and a session that works perfectly on your own domain vanishes the moment the API moves to a different one. The second shape is a token that left and was rejected for being old: a JWT's `exp` is a NumericDate in seconds (RFC 7519), so a millisecond timestamp written into it is roughly fifty thousand years in the future and a token minted in milliseconds and checked in seconds fails in ways that look random. The third is visual rather than logged — a browser dialog asking for a username and password appears over your app, which means the server answered with a `Basic` challenge and the browser did what the spec tells it to.
What to do
Read the request headers your browser actually sent in the network panel rather than the ones your code intends to send, and confirm `authorization` or the session cookie is on the failing request specifically. Then read the response's `www-authenticate` header, which §15.5.2 makes mandatory: its first token is the scheme, and for Bearer the `error` parameter says which of missing, malformed, expired or revoked it was, so you get the answer without a single server-side change. For cross-origin cookie sessions, pass `credentials: "include"` and make sure the server answers with `Access-Control-Allow-Credentials: true` and a concrete origin, because the wildcard is not permitted alongside credentials. And cap your retries: one refresh attempt then give up, since a refresh loop against an expired session is precisely how a token endpoint turns a 401 into a 429.
Why you are seeing it
Either the credential never arrived, or it arrived and your validator refused it — and since the status code cannot tell those apart, the intermediary is the suspect worth eliminating first. nginx discards request headers whose names contain underscores, because `underscores_in_headers` is off by default, so a client sending `X_Auth_Token` finds the header simply gone by the time the application reads it. Apache has its own version: `Authorization` is not passed through to CGI and FastCGI applications unless `CGIPassAuth` is turned on, which is why the same PHP application authenticates under one server and not the other. When the header does arrive, the refusals cluster: a signing key rotated on one side only, an issuer or audience claim that does not match what the validator expects, and a host whose clock has drifted far enough that `exp` or `nbf` checks reject tokens that are perfectly current.
What to do
Log the two cases as two different lines — “no credential presented” and “credential presented and rejected because X” — because that single distinction is most of the debugging, and the status code will never carry it. Make sure every 401 you emit carries a `WWW-Authenticate` challenge, since §15.5.2 requires it; for a Bearer API use RFC 6750's parameters (`Bearer error="invalid_token", error_description="..."`) so a client can act without opening a support ticket. Do not send a `Basic` challenge from an API that browsers call, or the browser will interrupt your application with its own credential dialog. Then prove the header survives the hop: log its presence at the proxy and at the application, and check `underscores_in_headers` or `CGIPassAuth` before you go looking at your token library. Finally, check the clock — `timedatectl` or `chronyc tracking` on the validating host — because skew produces intermittent 401s that no amount of reading the auth code will explain.
Why you are seeing it
The site is asking who you are, or has decided that the answer it was holding is no longer good enough. In practice that is a session that timed out, a sign-out that happened in another tab or on another device, a password that was changed somewhere else, or a link from an email or a shared document whose access was time-limited and has run out. It is not a fault with your device, your browser or your connection, and it is not the same thing as being told you are not allowed — this one is the site saying it does not currently know you.
What to do
Sign in again first, because that is the fix in most cases. If you are already signed in and still see it, sign out deliberately and back in, since the stale session lives at the site's end and only a fresh sign-in replaces it. If a small browser dialog asks for a username and password, that is the site's own protection rather than anything malicious, and it wants credentials that site gave you — never your email or your device password. Clearing that site's cookies is one of the few situations where it genuinely helps, because the expired session is stored in one. And if you arrived from a link someone shared, ask for a fresh one: links that grant access usually expire on purpose.
Run this against the URL that failed. It prints the status without the error page, so you can see what the server said rather than what the browser rendered.
curl -sS -o /dev/null -D - -w '\n%{http_code}\n' https://example.com/api/me`-sS` drops the progress meter while keeping error messages, `-o /dev/null` throws away the error page body, `-D -` dumps the response headers to stdout, and `-w` prints the status on its own line at the end. The header you came for is `www-authenticate`: §15.5.2 makes it mandatory on a 401, its first token is the scheme, and on a Bearer API the `error` parameter separates a missing token from an invalid one from an expired one. If that header is absent, the server is not answering to spec and its own logs are now your only source. Then run the command twice more to split the two cases the status code cannot: once with `-H 'authorization: Bearer <token>'` and once with `-u wrong:creds`. A 401 for both means the validator is refusing everything, which points at the key, the issuer or the audience rather than at your token; a 401 only when you send the real token points at that token being expired or revoked. Note that `-u` makes curl send Basic credentials preemptively, so a 401 in that run says nothing about whether the server ever offered Basic — only the `www-authenticate` header does. If the challenge names a proxy rather than the site, look at 407 instead.
RFC 9110 §15.5.2 defines 401 as the request not having been applied because it lacks valid authentication credentials for the target resource, and it attaches a hard requirement: a server generating a 401 MUST send a `WWW-Authenticate` header field carrying at least one challenge. That header is the whole difference between a 401 and a 403 — it exists because retrying with different credentials could plausibly succeed, so the server owes the client a machine-readable statement of which credentials. A 401 with no challenge is a spec violation, and it is also the version of this code that wastes the most of your afternoon, because it removes the only field that says what the server wanted. The same section covers the case people find surprising: if the request did include credentials, the 401 means authorization has been refused for those credentials. So one status code covers both “you sent nothing” and “you sent something and it was rejected”, and nothing in the status line separates them — which is why your own logs have to. §15.5.2 also tells a user agent that repeats the request and gets back the same challenge to show the response body to the user, since it usually carries the diagnostic detail. Two more facts change where you look. The challenge grammar in §11.3 is a scheme name followed by parameters, of which `realm` names the protection space, and the scheme is what decides browser behaviour: a `Basic` challenge (RFC 7617) makes the browser render its own username and password dialog on top of your application. And for token APIs, RFC 6750 §3.1 splits the cases by status rather than leaving them to taste — `invalid_request` is a 400, `invalid_token` is a 401, and `insufficient_scope` is a 403. Read that way, a 401 from an OAuth-protected API says the token was missing, malformed, expired or revoked, while a token that is genuinely yours but too narrow should have come back as 403. Finally, 401 is not among the status codes RFC 9110 §15.1 marks heuristically cacheable, so no proxy will cache one on its own initiative.
| Confused with | How to tell them apart |
|---|---|
| 403 | 401 says the server does not know who you are; 403 says it knows and the answer is still no. The reliable test is not the wording but the header: RFC 9110 requires `WWW-Authenticate` on a 401 because retrying with different credentials could work, and requires nothing of the sort on a 403 because it could not. RFC 6750 §3.1 draws the same line for OAuth — a token that is missing or invalid is `invalid_token` and a 401, while a valid token lacking the necessary scope is `insufficient_scope` and a 403. |
| 404 | A resource you are certain exists can answer 404 to an unauthenticated caller quite legitimately: §15.5.4 permits a server that does not want to admit a forbidden resource exists to send 404 instead, and private repositories and object storage do exactly that. So before you debug a 404 as a routing bug, retry it with credentials — if it turns into a 200 or a 403, it was an access decision in disguise all along. |
| 400 | An `Authorization` header the server cannot parse at all is a malformed request rather than a rejected identity, and RFC 6750 §3.1 says so explicitly: `invalid_request` should be answered with 400, not 401. Frameworks disagree in practice, so a 400 on an authenticated endpoint is worth reading as “your header is the wrong shape” — a missing `Bearer ` prefix, a stray newline, or two `Authorization` headers — before you assume the token itself was refused. |
407 | 407 (Proxy Authentication Required, §15.5.8) is the same idea one hop earlier: a proxy, not the origin, wants credentials, and it challenges with `Proxy-Authenticate` rather than `WWW-Authenticate`. On a corporate network a 401 you cannot explain is often a 407 you did not read carefully, and the giveaway is that the challenge arrives for every host you try rather than only for the API you are working on. |
Basic | The Basic scheme (RFC 7617) encodes `user:password` in base64, which is an encoding rather than encryption — anyone who can see the header can read the password, so it is only ever safe over TLS. It also has a user-interface consequence people rarely intend: a browser that receives a `Basic` challenge renders its own credential dialog over the page, which is why an API meant to be called from JavaScript should challenge with `Bearer` instead. |
All of these run in your browser — nothing is uploaded.
Codes people usually end up reading in the same session as 401.
Read the response headers rather than the page. curl -sS -o /dev/null -D - https://example.com/path prints them without the body, and server, via and cf-ray between them name the layer that answered. A cf-ray value means Cloudflare handled the response and is the id their support will ask for. To take the edge out of the picture entirely, repeat the request with --resolve example.com:443:203.0.113.10, which connects to the origin address you name while still sending the original host name and SNI — if the answer changes, the edge and the origin disagree.
No, and you should never branch on it. RFC 9112 tells clients to ignore the reason phrase, servers are free to change it, and HTTP/2 and HTTP/3 do not carry one at all — so 404 Not Found over HTTP/1.1 arrives as a bare 404 over HTTP/2. The phrases on this site are the registered ones because they are what people search for and what appears in an HTTP/1.1 log, not because any software depends on them.
Retry 5xx and 429; do not retry 4xx, because nothing about the request will be different next time. If the response carries Retry-After, honour it — RFC 9110 defines it for exactly this, and it may be either a number of seconds or a date. Otherwise use exponential backoff with jitter and a hard cap, and only for idempotent methods: a retried POST can charge a card twice. A retry storm against a server that is already failing is how a brief incident becomes a long one.
Yes, and the only rule that matters is that the code has to be true. Anything that reads your responses automatically — search engines, monitoring, caches, client retry logic — makes decisions from the number and never from the page. Serving an error page with 200 hides the failure from your own alerting; serving 200 for a missing page gets the error indexed as content; returning 500 for a request that was simply malformed sends whoever is on call to the wrong half of the stack.
Still stuck on 401? browse every status code in the reference — or go back up to the block written for your side of the stack.