401 vs 403: Unauthorized vs Forbidden (tested)
401 Unauthorized means the request has no valid credentials. Send good ones and it may work. 403 Forbidden means the server knows who you are (or does not care) and still says no. Logging in again will not help. We ran both against a real server.
1. The short rule
| 401 Unauthorized | 403 Forbidden | |
|---|---|---|
| Meaning | Not authenticated (missing, bad or expired credentials) | Authenticated or not, you are not allowed |
| Will new credentials help? | Yes, maybe | No (RFC 9110) |
WWW-Authenticate | Required by RFC 9110 | Not needed |
| Typical cause | No token, expired token, wrong password | Wrong role, IP block, WAF rule, file permissions |
| What the client should do | Log in / refresh the token, then retry | Show "no access"; do not loop on login |
2. A real API: no token gives 401, wrong role gives 403
Our Node server protects /admin. It accepts admin-token. It knows user-token but that user is not an admin. Here is what curl showed.
$ curl -i http://127.0.0.1:8993/admin
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api"
Content-Type: application/json
{"error":"no token"}
$ curl -i -H "Authorization: Bearer user-token" http://127.0.0.1:8993/admin
HTTP/1.1 403 Forbidden
Content-Type: application/json
{"error":"admin role required"}
$ curl -i -H "Authorization: Bearer admin-token" http://127.0.0.1:8993/admin
HTTP/1.1 200 OK
$ curl -i -H "Authorization: Bearer junk" http://127.0.0.1:8993/admin
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token"
A token we don't recognise is still a 401. Only a valid token with too few rights is a 403. The core of the handler:
if (!auth.startsWith('Bearer ')) {
res.writeHead(401, {'WWW-Authenticate': 'Bearer realm="api"'});
return res.end('{"error":"no token"}');
}
const user = lookup(auth.slice(7));
if (!user) {
res.writeHead(401, {'WWW-Authenticate': 'Bearer error="invalid_token"'});
return res.end('{"error":"bad token"}');
}
if (user.role !== 'admin') {
res.writeHead(403); // no WWW-Authenticate: a new login won't help
return res.end('{"error":"admin role required"}');
}
More on the token format in the Bearer token guide.
3. Basic auth: curl and the retry
A second route sends WWW-Authenticate: Basic realm="demo" until it gets alice:s3cret.
$ curl -i http://127.0.0.1:8993/basic
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="demo"
need login
$ curl -i -u alice:s3cret http://127.0.0.1:8993/basic
HTTP/1.1 200 OK
hello alice
$ curl -i -u alice:bad http://127.0.0.1:8993/basic
HTTP/1.1 401 Unauthorized
With -u curl sends Basic right away. With --anyauth it first sends no credentials, reads the challenge, and retries. The -v output showed two requests:
$ curl -v --anyauth -u alice:s3cret http://127.0.0.1:8993/basic
> GET /basic HTTP/1.1
< HTTP/1.1 401 Unauthorized
< WWW-Authenticate: Basic realm="demo"
> GET /basic HTTP/1.1
> Authorization: Basic YWxpY2U6czNjcmV0
< HTTP/1.1 200 OK
hello alice
A wrong password is a 401, not a 403. The server can't tell who you are, so new credentials might still work. For nginx setup see WWW-Authenticate Basic in nginx. Proxies use a different pair, 407 and Proxy-Authorization: see the Proxy-Authorization guide.
4. Browsers: the login prompt
From the docs: a desktop browser shows its own username/password dialog when a page navigation gets a 401 with WWW-Authenticate: Basic. A 401 without that header (or with Bearer) gets no dialog; the page body is shown.
What we measured in headless Chromium 153 (Playwright), where no dialog can appear:
goto /basic (401 + Basic challenge) -> status 401, body "need login"
goto /noauth (401, no challenge) -> status 401, body "401 no challenge"
goto /basic with httpCredentials alice -> status 200, body "hello alice"
Headless did not hang. It just showed the 401 body. When credentials were set on the context, Chromium answered the challenge and got 200.
5. fetch() does not throw on 401 or 403
We called the API from Node 24's built-in fetch:
const r = await fetch('http://127.0.0.1:8993/admin');
console.log(r.status, r.ok, r.headers.get('www-authenticate'));
// 401 false Bearer realm="api"
const r2 = await fetch(url, {headers: {Authorization: 'Bearer user-token'}});
// 403 false null
No exception either time. fetch only rejects on network errors. Check res.ok or res.status yourself. From the docs: in a browser, cookies are not sent on a cross-origin fetch unless you pass credentials: 'include' (and the server allows it with CORS). A missing cookie often looks like a 401. Also check the cookie's SameSite value: SameSite Lax, Strict and None.
6. Sometimes a 403 is dressed up as a 404
We served a folder with python3 -m http.server and ran chmod 000 locked.txt. The server process could not read the file.
$ curl -i http://127.0.0.1:8994/locked.txt
HTTP/1.0 404 File not found
Server: SimpleHTTP/0.6 Python/3.13.5
$ curl -i http://127.0.0.1:8994/missing.txt
HTTP/1.0 404 File not found
A file that exists but can't be read and a file that doesn't exist got the same answer. So a 404 does not prove the thing is missing. From the docs: GitHub does this on purpose. It returns 404 for a private repo you can't see, so it doesn't reveal that the repo exists.
7. Other common 403 sources (from the docs)
- nginx: a directory with no index file and
autoindex offgives 403. So does a file the nginx worker user can't read. - Cloudflare or another WAF: a firewall rule, bot check or IP block gives 403 before your app sees the request.
- Cloud storage: a bucket policy or a signed URL that doesn't match.
In all of these, logging in again does nothing. Look at the response body and the Server header to see who sent the 403.
8. Which one should my API send?
- No token, bad token, expired token: 401 with
WWW-Authenticate. - Good token, not allowed: 403.
- Don't want to admit the thing exists: 404.
Frequently asked questions
What is the difference between 401 and 403?
401 means the server has no valid credentials for you, so logging in may fix it. 403 means the server refuses even though it knows who you are (or does not care), so logging in again will not help.
Does a 401 need a WWW-Authenticate header?
Yes. RFC 9110 says a 401 must include WWW-Authenticate. Our Node server sent 'Bearer realm="api"'. curl --anyauth used the Basic challenge to retry and got a 200.
Is a wrong password a 401 or a 403?
A 401. The server can't confirm who you are, so other credentials might work. In our test, curl -u alice:bad got 401 Unauthorized.
Why does fetch() not throw on a 401?
fetch only rejects on network failures. In Node 24 a 401 and a 403 both resolved with res.ok = false. Check res.status yourself.
Why do I get 404 when I expected 403?
Some servers hide forbidden things as 404. Python's http.server returned '404 File not found' for a chmod 000 file. GitHub returns 404 for private repos you can't access.
Written with AI assistance. Measured on 2026-10-02 with curl 8.14.1, Node.js 24.21.0 (built-in fetch), Python 3.13.5 http.server and headless Chromium 153 via Playwright: a small Node API on 127.0.0.1:8993 that checks a Bearer token and a role, plus a Basic-auth route, and a Python static server on 127.0.0.1:8994 serving a chmod 000 file. RFC, GitHub, nginx and Cloudflare notes are from their docs, not measured. PlainHeaders is our extension; curl, Python, Node.js and Chromium are free.