A 401 means the server does not know who you are. A 403 means it knows exactly who you are and you are not allowed. Getting these backwards is one of the most common API design mistakes, and it produces client code that retries authentication that was never the problem.
The confusion is partly the specification’s fault — 401 is named Unauthorized when it means unauthenticated, and the RFC itself notes the name is misleading. But the distinction is real and it drives real client behaviour: a 401 tells a client to obtain credentials and try again, while a 403 tells it that trying again is pointless. Here is how to get it right on both sides.
Table of contents
- The distinction, stated so it sticks
- WWW-Authenticate is not optional
- The bugs that produce spurious 401s
- Designing the responses on your own API
- Handling 401 well on the client
- How this fits the rest of the stack
- FAQ
The distinction, stated so it sticks
The clearest formulation, which has circulated for years because it works: 401 is I do not know who you are. 403 is I know who you are, and no.
The practical consequence is what the client should do next:
- 401 — obtain credentials, or refresh the ones you have, and retry. A login prompt or a token refresh is the right response.
- 403 — do not retry with the same identity. Nothing about repeating the request will change the answer. Either the account needs different permissions or the resource is not for this user.
So an API that returns 401 for a permission failure sends the client into a pointless re-authentication loop. The user logs in again, succeeds, gets 401 again, and concludes the login is broken. This is a genuinely common and genuinely confusing bug.
The reverse error — 403 when the token has simply expired — is equally bad in a different way. The client concludes it lacks permission and gives up, when refreshing the token would have worked immediately.
There is one legitimate reason to return 403 where 401 would be technically correct: not revealing whether a resource exists. Returning 404 for both missing and forbidden resources is a deliberate information-disclosure defence, and it is a considered choice rather than a mistake.
WWW-Authenticate is not optional
The specification requires that a 401 response include a WWW-Authenticate header describing how to authenticate. A 401 without it is malformed, and a surprising number of APIs send exactly that.
The header names the scheme and any parameters:
WWW-Authenticate: Basic realm="api"— the browser will show a native username and password dialog.WWW-Authenticate: Bearer— token-based, the standard for APIs.WWW-Authenticate: Bearer error="invalid_token", error_description="The access token expired"— the useful form, which tells the client why rather than just no.
That last form matters more than it looks. A client receiving invalid_token with an expiry description knows to refresh. A client receiving a bare 401 has to guess whether to refresh, re-authenticate from scratch, or give up — and different clients will guess differently.
One practical caution: sending WWW-Authenticate: Basic from a browser-facing API triggers the browser’s native credential dialog, which is almost never what you want in a single-page application. Use Bearer, and handle the 401 in your own code.
The bugs that produce spurious 401s
In rough order of how often they waste an afternoon.
- Clock skew. Tokens carry issued-at and expiry timestamps. A server whose clock is off by minutes will reject valid tokens as expired or not-yet-valid. Symptom: authentication works on one instance and not another. Fix: working time synchronisation, and a small leeway when validating.
- The header not arriving. Proxies, load balancers and some frameworks strip or rewrite
Authorization. The client sends it, the application never sees it. Log what actually reached the handler rather than what you believe was sent. - Case and format. The scheme is case-insensitive but the space matters —
Bearer <token>with exactly one space. Concatenation bugs that produceBearer<token>or a doubledBearer Bearerare common and produce a bare parse failure. - Expired refresh token. The access token refresh silently fails because the refresh token itself expired, and the client loops trying to refresh. Handle refresh failure distinctly from access failure.
- CORS preflight. A browser’s
OPTIONSpreflight carries no credentials by design. If your middleware requires authentication on every method including OPTIONS, the preflight gets a 401 and the real request is never sent — appearing to the developer as a CORS error rather than an auth one. - Cookie attributes. A session cookie marked
SameSite=Strictis not sent on cross-site requests, and one markedSecureis not sent over plain HTTP. Both produce a 401 that looks like a broken session.
The CORS preflight one is worth flagging because the error message points somewhere entirely unhelpful. If a cross-origin authenticated request fails and the browser reports a CORS problem, check whether OPTIONS is returning 401.
Designing the responses on your own API
A small set of rules that avoids the whole category of confusion.
- 401 when identity is absent, malformed or expired. Include
WWW-Authenticatewith an error code the client can branch on. - 403 when identity is established and the action is not permitted. Do not include
WWW-Authenticate— there is nothing to re-authenticate with. - 404 when you must not reveal existence. Applied consistently, so the absence of a 403 is not itself a signal.
- Never leak why authentication failed to an unauthenticated caller. No user does not exist versus wrong password — that is an account enumeration oracle. A generic failure for both.
- A machine-readable error body, with a stable code field. Clients should branch on a code, never on parsing a human-readable message that will be reworded eventually.
Also: rate limit authentication endpoints, per-account and per-address, with a delay rather than a lockout. Credential stuffing relies on volume, and a delay makes it expensive without letting an attacker lock out a real user by trying their email repeatedly.
And log authentication failures with enough context to investigate — timestamp, account, source address, failure reason — while making sure the credentials themselves never reach the log. A log containing tokens is a credential store you did not intend to create.
Handling 401 well on the client
The client side has a specific shape that avoids the two failure modes: infinite loops and lost requests.
- Intercept 401 in one place. A single interceptor in your HTTP client, not a check at every call site.
- Refresh once, then retry the original request. Not the whole page, not a redirect to login — the specific request that failed, once the token is fresh.
- Guard against concurrent refreshes. Ten requests failing simultaneously must not trigger ten refreshes. The first starts a refresh, the rest wait on that promise. Without this you get a burst that may itself be rate limited, and some providers invalidate old refresh tokens on use, which turns a burst into a logout.
- Do not retry more than once. If a request fails with 401 after a successful refresh, the problem is not the token. Fail and surface it.
- On refresh failure, clear state and send the user to login. Cleanly, with a return path so they land back where they were.
The concurrent refresh guard is the one most often missing, and it produces an intermittent bug that is very hard to reproduce: users get logged out at random, usually on the page that happens to make several parallel requests on load.
How this fits the rest of the stack
Authentication behaviour is easiest to debug when the failing request and the deploy that changed it are visible in the same place — a 401 that started this afternoon is a different investigation from one that has always been there. The RunxBuild hosting calculator covers what the surrounding stack costs: the service, the database holding sessions, the storage and the bandwidth. On RunxBuild, build and runtime logs sit together per deploy, secrets are injected as environment variables rather than committed, and rolling back to the previous working deploy is a single action.
Useful related references:
- What Is a 401 Error: Unauthenticated, Not Unauthorised
- Health Check Response Protocol for APIs That Stay Up
- Getting the Same Response Each Time From a Model in n8n
- Services on RunxBuild
FAQ
What is the difference between 401 and 403?
401 means the server does not know who you are — credentials are missing, malformed or expired, and obtaining them and retrying is the right move. 403 means it knows who you are and the action is not permitted, so retrying with the same identity is pointless. The 401 name Unauthorized is misleading; it means unauthenticated.
Why does my API return 401 when the user is logged in?
Common causes are clock skew making a valid token look expired, a proxy stripping the Authorization header before it reaches your application, a malformed header such as a missing space after Bearer, or a cookie whose SameSite or Secure attributes stop it being sent. Log what actually arrived at the handler rather than what you believe was sent.
Is WWW-Authenticate required on a 401?
Yes — the specification requires it, and a 401 without it is malformed. Include an error code and description, such as Bearer error=invalid_token, so the client knows whether to refresh a token or re-authenticate from scratch rather than guessing.
Why does my authenticated cross-origin request fail with a CORS error?
Often because the browser’s OPTIONS preflight is getting a 401. Preflight requests carry no credentials by design, so middleware that requires authentication on every method rejects them and the real request is never sent. Exempt OPTIONS from authentication.
How should a client handle a 401?
Intercept it in one place, refresh the token once, and retry the original request. Guard against concurrent refreshes so several simultaneous failures trigger one refresh rather than many — some providers invalidate old refresh tokens on use, which turns a burst into an unexpected logout. Do not retry more than once.