Understanding HTTP 401 Unauthorized: Authentication, WWW-Authenticate, and the 401 vs 403 Trap
401 Unauthorized has a misleading name. Read it literally and you'd think it means "you're not allowed" — but that's actually 403's job. 401 is about authentication: proving who you are. The word "unauthorized" in the spec is a historical accident, and untangling it from "authorization" is the single most useful thing you can learn about this code. Let's go through what 401 really means, the header it's required to send, when to reach for it instead of 403 or 407, and how to return and handle it without shooting yourself in the foot.
What Is a 401?
A 401 means the server couldn't apply your request because you haven't proven your identity — you sent no credentials, or the ones you sent are invalid or expired.
The 401 (Unauthorized) status code indicates that the request has not been applied because it lacks valid authentication credentials for the target resource. — RFC 9110, Section 15.5.2
In plain English: "I don't know who you are, and I need to before I can do this." The fix for a 401 is almost always authenticate and try again — log in, refresh your token, send the right header.
401 Is About Authentication, Not Authorization
This is the trap, so it's worth stating bluntly. There are two distinct questions a server asks about a request:
- Authentication — Who are you? Can you prove your identity? (401's domain.)
- Authorization — Are you allowed? Does your proven identity have permission? (403's domain.)
A 401 says you failed the first question. You never got far enough for the second one to matter. The mental model that keeps it straight:
- 401 = "Who are you?" — You haven't proven it. Authenticating again can fix this.
- 403 = "I know who you are, and no." — You're authenticated fine; you just don't have permission. Authenticating again cannot fix this — you'd need a different role, plan, or grant.
If logging in or sending a fresh token could plausibly resolve the situation, it's a 401. If the user is already who they say they are and still can't proceed, it's a 403.
The WWW-Authenticate Header Is Mandatory
This is the part most implementations get wrong. A 401 isn't just a status number — the spec requires you to tell the client how to authenticate:
The server generating a 401 response MUST send a WWW-Authenticate header field containing at least one challenge applicable to the target resource. — RFC 9110, Section 15.5.2
The WWW-Authenticate header carries a challenge — the scheme the client should use:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: BearerCommon schemes:
Bearer— token auth (OAuth 2.0, JWT). By far the most common in modern APIs. You can add detail:WWW-Authenticate: Bearer error="invalid_token", error_description="The access token expired".Basic— username/password, base64-encoded. Triggers the browser's native login dialog:WWW-Authenticate: Basic realm="Admin Area".Digest— a hashed-credential scheme, rarely used in new systems.
Omitting WWW-Authenticate is the most common 401 spec violation in the wild. It's tempting to skip — APIs return a bare 401 and move on — but it leaves clients (and browsers) with no machine-readable signal about how to recover. At minimum, send the scheme.
401 vs 403 vs 407
Three codes deal with "you can't have this yet," and they're routinely mixed up. Choose deliberately:
| Code | Meaning | Use when |
|---|---|---|
| 401 Unauthorized | Not authenticated | No credentials, or they're invalid/expired |
| 403 Forbidden | Authenticated but not allowed | Valid identity, but lacks permission (wrong role, plan, region) |
| 407 Proxy Authentication Required | Must authenticate to a proxy | An intermediary needs auth, not the origin server |
407 is the odd one out: it's structurally identical to 401 but the challenge comes from a proxy, so it uses Proxy-Authenticate instead of WWW-Authenticate. You'll mostly see it on corporate networks.
The heuristic again: 401 = "Who are you?", 403 = "I know who you are, and no.", 407 = "The proxy in the middle needs to know who you are."
Common Causes
In practice, 401s come from a short list of repeat offenders:
- Missing token — No
Authorizationheader at all. The client forgot to attach it, or a logged-out user hit a protected route. - Expired session or token — The JWT's
exppassed, or the server-side session timed out. The credentials were valid; they aren't anymore. - Invalid credentials — Wrong password, malformed token, a token signed with a key the server doesn't trust.
- Clock skew — A subtle one: if the client and server clocks disagree by more than the allowed leeway, a perfectly fresh JWT can read as expired (or not-yet-valid via
nbf). Sync clocks with NTP and allow a few seconds of leeway when validating. - Malformed
Authorizationheader — SendingAuthorization: <token>instead ofAuthorization: Bearer <token>. The scheme prefix is part of the format. - Revoked or rotated token — An OAuth access token that was revoked, or signed with a key that has since rotated out of the JWKS.
Returning 401 Correctly Across Platforms
Express / Node.js
function requireAuth(req, res, next) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
res.set("WWW-Authenticate", 'Bearer realm="api"');
return res.status(401).json({ error: "missing_token" });
}
try {
req.user = verifyJwt(token);
next();
} catch {
res.set(
"WWW-Authenticate",
'Bearer error="invalid_token", error_description="Token is invalid or expired"',
);
return res.status(401).json({ error: "invalid_token" });
}
}Note the WWW-Authenticate header on both branches — missing and invalid. That's what the spec asks for.
Next.js App Router
In a route handler, return a NextResponse with the status and challenge header:
// app/api/me/route.ts
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const token = request.headers.get("authorization")?.split(" ")[1];
if (!token || !(await isValid(token))) {
return NextResponse.json(
{ error: "invalid_token" },
{
status: 401,
headers: { "WWW-Authenticate": 'Bearer realm="api"' },
},
);
}
return NextResponse.json({ user: await getUser(token) });
}For page-level protection, prefer redirecting unauthenticated users to a login route in middleware — a 401 on an HTML navigation is awkward UX. Reserve raw 401s for API endpoints and fetch calls.
NGINX
location /admin/ {
auth_basic "Admin Area";
auth_basic_user_file /etc/nginx/.htpasswd;
}auth_basic makes NGINX send 401 with WWW-Authenticate: Basic realm="Admin Area" automatically, triggering the browser's login prompt. To return a 401 by hand (e.g., from an auth_request subrequest), set the header explicitly with add_header WWW-Authenticate ... always;.
REST API JSON body
For machine consumers, pair the header with a structured body so clients can branch on a stable code:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token"
Content-Type: application/json
{
"error": "invalid_token",
"message": "The access token has expired.",
"documentation": "https://api.example.com/docs/errors#invalid_token"
}Don't return 200 OK with { "error": "unauthorized" }. Any decent HTTP client treats 2xx as success, your token-refresh logic will never trigger, and you'll special-case every call site forever.
Handling 401 on the Client
The classic pattern is the silent refresh-and-retry: a 401 means "your access token is stale," so refresh it once and replay the request.
async function fetchWithAuth(input, init = {}) {
let res = await fetch(input, withToken(init, getAccessToken()));
if (res.status === 401) {
const refreshed = await refreshAccessToken(); // uses the refresh token
if (!refreshed) {
redirectToLogin();
return res;
}
res = await fetch(input, withToken(init, getAccessToken())); // retry once
}
return res;
}Two things to get right:
- Retry exactly once. If the request still 401s after a refresh, stop and send the user to log in. Looping on refresh is how you get an infinite request storm against your auth server.
- Don't treat 403 like 401. A 403 means refreshing won't help — the user is authenticated but lacks permission. Refreshing and retrying a 403 just wastes a round trip and hides the real problem from the user. Branch on the status code.
401 and SEO
Search crawlers can't log in. When Googlebot hits a 401, it sees a locked door and moves on — the content behind it never gets indexed.
- Don't gate SEO-important content behind 401. If a page needs to rank, it has to be reachable without credentials.
- Offer a public preview. A common pattern for paywalled or member content is to serve a meaningful summary (indexable, 200 OK) and put only the full content behind auth.
- 401 is a soft signal. Crawlers may re-check a 401 URL periodically in case access rules change, but they won't index what they can't read.
Common Pitfalls
- Using 403 where 401 belongs (and vice versa). Returning 403 to a logged-out user is wrong — they could fix it by logging in, so it's a 401. Returning 401 to a logged-in user who lacks permission is also wrong — re-authenticating won't help, so it's a 403.
- Omitting
WWW-Authenticate. A bare 401 with no challenge violates the spec and leaves clients guessing how to recover. - Returning 200 with an error body. Breaks every client's success/failure branching and silently disables token-refresh flows.
- Leaking resource existence via inconsistent codes. If
/users/42returns 403 (exists, no access) but/users/99returns 404 (doesn't exist), an attacker can enumerate which IDs are real. For sensitive resources, return the same code regardless — usually 404 to deny existence entirely. - 401 → login redirect loops. If your login page itself sits behind the auth check, an unauthenticated user gets redirected forever. Exempt the auth routes.
- Confusing an expired token with an invalid one. Both are 401, but distinguishing them in
WWW-Authenticate(error="invalid_token"with a description) lets clients decide whether to refresh or force a full re-login.
Wrapping Up
401 is small but routinely misused. The rules of thumb:
- 401 is about authentication (who you are), not authorization (what you're allowed) — that's 403
- Always send a
WWW-Authenticatechallenge; it's required, not optional - Use 401 when credentials are missing/invalid/expired, 403 when the user is authenticated but lacks permission, 407 when a proxy needs auth
- On the client, refresh-and-retry once, then redirect to login — and never retry a 403
- Never return
200with an error body, and keep 401/403/404 consistent so you don't leak which resources exist
For more, see our pages on 401 Unauthorized, 403 Forbidden, and 407 Proxy Authentication Required. The line between "doesn't exist" and "you can't see it" comes up again in our understanding 404 Not Found post, and you can compare 401 vs 403 side by side.