Server

401 Unauthorized — What It Really Means and How to Fix It

Updated 2026-08-31~9 min read

401 Unauthorized is one of the worst-named status codes in HTTP. It does not mean you lack permission — that is 403. It means the server could not work out who you are at all, and it is inviting you to prove it.

That distinction decides where you look. A 401 is an authentication problem: a missing, expired, malformed or rejected credential. A 403 is an authorization problem: your identity was established and it simply is not allowed. Fixing a 401 means fixing how credentials are sent; fixing a 403 means changing a rule.

This guide covers the practical causes, the header most people forget is mandatory, and one proxy behaviour that silently breaks authentication on perfectly correct code.

401 vs 403 vs the others

These get used interchangeably in real APIs, which is exactly why debugging them is confusing. Here is what each one is supposed to mean.

CodeActual meaningCan the caller fix it?
401 UnauthorizedNot authenticated — identity unknown or rejectedYes — send valid credentials
403 ForbiddenAuthenticated, but not permittedNo — the owner must change a rule
407 Proxy Authentication RequiredThe proxy in between wants credentialsYes — authenticate to the proxy
419 / 440 (non-standard)Session or CSRF token expiredYes — refresh the page or token
💡 A useful rule when writing an API: if sending different credentials could change the outcome, return 401. If nothing the caller sends would help, return 403. Getting this right saves whoever debugs it later a great deal of time.

The header everyone forgets

The HTTP specification is explicit: a 401 response must include a WWW-Authenticate header telling the client how to authenticate. A 401 without it is technically an invalid response.

In practice a lot of APIs return a bare 401 with a JSON body and no such header. Browsers and HTTP clients rely on it to know what to do next — it is the header that makes a browser show the built-in username and password box for basic auth.

If you are building an API, send it: "WWW-Authenticate: Bearer" for token auth, or "WWW-Authenticate: Basic realm=..." for basic auth. If you are debugging someone else's API, its absence tells you the 401 is hand-rolled rather than coming from a standard auth layer, which is a useful clue about where to look.

Common causes, most frequent first

  • The token expired. Access tokens are usually short-lived by design; the fix is refreshing them, not extending their lifetime.
  • Wrong or missing API key. Check you are sending the key for the right environment — staging keys against production endpoints produce exactly this.
  • Malformed Authorization header. "Bearer" with the wrong capitalisation, a missing space, or a stray newline from copy-paste all fail silently.
  • Basic auth misconfigured on the server. A wrong path to .htpasswd, or a password file written with the wrong hashing format.
  • The session expired. On a normal website this is the ordinary "you have been logged out" case, and it is not a bug.
  • Clock skew with JWTs. If the server clock differs by more than the token tolerance, valid tokens are rejected as not-yet-valid or expired. Check the time on both machines.
  • The Authorization header was stripped in transit — covered in the next section, because it is the one that wastes the most time.

The proxy that eats your Authorization header

This one deserves its own section because the code is correct, the credentials are correct, and it still returns 401.

Some reverse proxy and CGI setups drop the Authorization header before it reaches the application. Apache with mod_cgi and PHP-FPM is the classic case: unless CGIPassAuth is enabled or the header is explicitly forwarded, PHP never sees it and every authenticated request looks anonymous.

The symptom is distinctive: the same request works when sent directly to the application port, but fails through the public URL. If you can reproduce that difference, the proxy layer is the culprit, not your auth code.

How to confirm: log the raw headers your application actually receives. If Authorization is absent there while your client is definitely sending it, something between the two removed it. In Apache add "CGIPassAuth On"; in Nginx make sure your proxy_pass block is not filtering it; on managed hosting, ask support whether the header is passed through.

💡 The same class of problem hits custom headers. If an API works locally and fails in production, compare the exact headers the server receives in both places before touching the authentication logic.

Check in this order

  • Confirm the credential is actually being sent. Log the request headers on the client side, or use "curl -v" — the -v flag prints what was sent.
  • Test the same credential with curl directly against the endpoint. If curl works and your application does not, the problem is in your client, not the server.
  • Check the token is not expired. Decode a JWT at any decoder and read the exp claim; do not assume, verify.
  • Compare server clocks if JWTs are involved. Even a couple of minutes of skew rejects valid tokens.
  • Log the headers the server actually receives. This is where stripped Authorization headers show up.
  • Check whether the endpoint expects a different scheme. Some APIs want "Bearer <token>", others want an "X-API-Key" header, others a query parameter. Sending the right value the wrong way still returns 401.
  • Read the response body. Many APIs return a specific error code inside the JSON that distinguishes expired from invalid from revoked — three different problems with three different fixes.

If you are protecting your own site

When you are the one issuing the 401, a few choices make it much easier for everyone to work with.

Always send WWW-Authenticate, as above. It is required, and it is what tells clients how to proceed.

Distinguish expired from invalid in the response body. "Token expired" tells the client to refresh; "token invalid" tells it to re-authenticate from scratch. Returning the same opaque message for both forces the client to guess.

Do not use 401 to hide the existence of a resource. If you want to conceal whether something exists, return 404 — that is the established pattern. A 401 confirms there is something there worth authenticating for.

Rate-limit failed authentication attempts. Endpoints that return 401 are exactly what credential-stuffing tools hammer, and an unlimited login endpoint is an open invitation.

Keep basic auth behind HTTPS only. Basic auth sends credentials in a trivially decodable form; over plain HTTP they are effectively in the clear.

Need to see the headers your server actually receives?

Cloud NVMe VPS with full root access — real logs, your own proxy and auth configuration. From ฿150/month.

Frequently Asked Questions

What is the difference between 401 and 403?

401 means the server does not know who you are — authentication failed or was never provided, and sending valid credentials could fix it. 403 means you have been identified and still are not allowed, so no credential change will help. HTTP named them backwards: 401 is really "unauthenticated" and 403 is "unauthorized".

My API key is correct but I still get 401. Why?

Check three things in order: that the header is actually reaching the server (log the received headers), that you are using the scheme the API expects (Bearer vs X-API-Key vs query parameter), and that the key belongs to the environment you are calling. A staging key against a production endpoint returns 401 with no other clue.

Why does authentication work locally but fail in production?

Very often a reverse proxy or CGI layer is stripping the Authorization header before your application sees it. Apache with PHP is the classic case and needs CGIPassAuth enabled. Log the raw headers your app receives in both environments and compare — that comparison usually finds it in a minute.

Does a 401 hurt SEO?

Only if it appears on pages that should be public. Googlebot cannot authenticate, so any page behind a 401 will not be indexed — which is correct behaviour for genuinely private areas. It becomes a problem when an overly broad auth rule accidentally covers public pages; use URL Inspection in Search Console to see what Googlebot actually receives.