HTTP · commonly confused
401 Unauthorized vs 403 Forbidden
Short answer
Reproduced on curl 8.7.1 and Python 3.9.6, . Where the text says “reproduced”, the behaviour was run on that version, not taken from documentation.
First published
On this page · 10 sections
In simple terms
Both say no, and they say no for different reasons. A 401 means "I do not know who you are": the request arrived without credentials, or with credentials that were missing, expired, malformed or wrong. The fix is to authenticate. A 403 means "I know what you are asking and I will not do it": maybe you are signed in and simply lack permission, maybe the rule has nothing to do with who you are at all. Signing in again as the same user does not help.
The confusing part is the name. 401 is officially called Unauthorized, which sounds like a permissions problem, but its definition is about authentication — whether the server has valid credentials at all. Permission — whether this identity may do this thing — is what 403 covers. Read literally, the names point the wrong way.
The difference matters because clients react to them differently. A well-behaved client that receives a 401 tries to authenticate — it refreshes a token, or sends the user to a login screen. A client that receives a 403 should not keep retrying with the same credentials, because the answer will not change. Send the wrong one and you get the wrong behaviour: an endless login loop, or a user who is never asked to sign in at all.
A 403 also does not always come from your application. The HTTP specification notes a request can be forbidden for reasons unrelated to credentials, which can mean something in front of the application — a firewall rule, an IP allowlist, a bot filter — refused it before your code ever ran.
Words used on this page
- authentication
- Proving who you are, for example with a password or a token. 401 is about this, despite its name.
- authorization
- Deciding what a known identity may do. 403 is about this. The request header called
Authorizationactually carries the credentials used for authentication. - credentials
- Whatever the client sends to prove its identity: a username and password, or a token.
- WWW-Authenticate
- The response header that tells the client how to authenticate. RFC 9110 requires it on every 401, and allows it on other responses.
- challenge
- One entry in
WWW-Authenticate: a scheme name such asBasicorBearer, followed by parameters such asrealm. - realm
- A name for a protected area of a server, sent in the challenge so the client knows which credentials apply.
- bearer token
- A token that works for whoever holds it, sent as
Authorization: Bearer <token>(RFC 6750). Anyone who copies it can use it. - scope
- What a token is allowed to do, decided when it was issued. A valid token with too little scope gets 403 with
error="insufficient_scope", not 401. - web application firewall
- A filter in front of the application, often called a WAF, that can refuse a request with its own 403 before your code runs.
What actually happens
RFC 9110 defines 401 as the request not being applied because it lacks valid authentication credentials for the target resource, and makes one header mandatory: a server generating a 401 MUST send a WWW-Authenticate header field containing at least one challenge for the resource. If the request did include credentials, the 401 means authorization was refused for those credentials, and the user agent MAY retry with a new or replaced Authorization header.
403 is defined as the server understanding the request but refusing to fulfill it. If credentials were provided, the server considers them insufficient; the client SHOULD NOT automatically repeat the request with the same credentials, though it MAY try different ones. The specification adds that a request might be forbidden for reasons unrelated to the credentials. MDN puts the client consequence plainly: repeating a 403 request without modification should be expected to fail the same way.
Hiding a resource's existence is allowed. RFC 9110 says an origin server that wishes to hide a forbidden resource MAY respond with 404 instead, and MDN describes the same practice — useful when confirming that a private resource exists would itself leak information, at the cost of a less helpful error for legitimate users.
The practical diagnostic is the WWW-Authenticate header. A genuine 401 carries it, naming the scheme the server expects. A 403 does not need one, though it may carry one: RFC 6750 has a bearer token API answer a token without enough scope with 403 and error="insufficient_scope" in the challenge. Either way, the 403's origin is worth establishing first: a response generated by an upstream proxy, CDN or web application firewall will differ in headers and body from one your application produced, and inspecting the response headers can show which layer answered.
Reading which one you have, from the headers
# A real 401 must carry a challenge naming the auth scheme.
curl -s -o /dev/null -D - https://api.example.com/private | grep -iE '^HTTP/|^www-authenticate'
# HTTP/2 401
# www-authenticate: Bearer realm="api"
# A 403 needs no challenge (bearer APIs may add one with error="insufficient_scope"); the same token will not help.
curl -s -o /dev/null -D - -H "Authorization: Bearer <token>" https://api.example.com/admin \
| grep -iE '^HTTP/|^www-authenticate|^server'
# HTTP/2 403
# server: <whatever answered> <- an edge proxy or WAF may name itself here
# Same request without the token: should become 401, not stay 403.
curl -s -o /dev/null -w '%{http_code}\n' https://api.example.com/adminThe host and token are placeholders. If removing valid credentials still returns 403, either the refusal is not about your identity, so look at whatever sits in front of the application, or the application sends 403 where RFC 9110 calls for 401.
Try it yourself
Before you start
- A terminal with
curl(curl --version). The example on this page was reproduced with curl 8.7.1. - An endpoint you are allowed to test: your own API, ideally a local or staging copy. To follow the example exactly you need Python 3 for the toy server it describes, which listens on 127.0.0.1 only.
- Two test credentials: one for a user who should be allowed and one for a user who should not. Use test accounts, never a production token borrowed from someone else.
- A map of what sits in front of the application, such as a load balancer, CDN, API gateway or web application firewall, and a way to reach the application without them. Without it you cannot tell which layer sent a 403.
One endpoint, four requests: 401, 401, 403 and 200
Reproduced on curl 8.7.1 and Python 3.9.6 on macOS, with the time zone set to UTC. The server is a toy API written for this page with Python's http.server, listening on 127.0.0.1 port 18620. GET /admin expects Authorization: Bearer <token>: user-token has only a read scope and admin-token also has admin. It answers the way RFC 9110 and the bearer token spec, RFC 6750, describe: 401 with a challenge when there is no token or an unknown one, 403 for a known token without the admin scope. Nothing in the output identifies this machine: 127.0.0.1 is the loopback address and the tokens are made-up test values.
Command
curl -i http://127.0.0.1:18620/admin curl -i -H "Authorization: Bearer expired-token" http://127.0.0.1:18620/admin curl -i -H "Authorization: Bearer user-token" http://127.0.0.1:18620/admin curl -i -H "Authorization: Bearer admin-token" http://127.0.0.1:18620/admin
Output
$ curl -i http://127.0.0.1:18620/admin
HTTP/1.1 401 Unauthorized
Server: BaseHTTP/0.6 Python/3.9.6
Date: Mon, 12 Oct 2026 12:17:21 GMT
WWW-Authenticate: Bearer realm="api"
Content-Type: application/json
Content-Length: 37
{"error": "authentication required"}
$ curl -i -H "Authorization: Bearer expired-token" http://127.0.0.1:18620/admin
HTTP/1.1 401 Unauthorized
Server: BaseHTTP/0.6 Python/3.9.6
Date: Mon, 12 Oct 2026 12:17:21 GMT
WWW-Authenticate: Bearer realm="api", error="invalid_token"
Content-Type: application/json
Content-Length: 27
{"error": "invalid token"}
$ curl -i -H "Authorization: Bearer user-token" http://127.0.0.1:18620/admin
HTTP/1.1 403 Forbidden
Server: BaseHTTP/0.6 Python/3.9.6
Date: Mon, 12 Oct 2026 12:17:21 GMT
WWW-Authenticate: Bearer realm="api", error="insufficient_scope", scope="admin"
Content-Type: application/json
Content-Length: 34
{"error": "admin scope required"}
$ curl -i -H "Authorization: Bearer admin-token" http://127.0.0.1:18620/admin
HTTP/1.1 200 OK
Server: BaseHTTP/0.6 Python/3.9.6
Date: Mon, 12 Oct 2026 12:17:21 GMT
Content-Type: application/json
Content-Length: 13
{"ok": true}How to read it
- No token:
401 UnauthorizedwithWWW-Authenticate: Bearer realm="api". The challenge names the scheme the server wants, so the client knows to get a token and send it. RFC 9110 requires this header on every 401. - A token the server does not accept: still 401, now with
error="invalid_token". Credentials arrived and were refused, so the fix is still to authenticate: refresh or replace the token. - A valid token without the admin scope:
403 Forbidden. The server knows who is asking and refuses anyway. Sending the same token again gets the same answer; only a token with more privilege would change it. - The 403 carries
WWW-Authenticatetoo, witherror="insufficient_scope"and thescopeit needs. RFC 9110 allows the header on any response, and RFC 6750 tells bearer token APIs to send it here. So the header alone does not separate the two: read the status line first, then theerrorvalue. - With
admin-tokenthe answer is200 OKand there is no challenge.Server: BaseHTTP/0.6 Python/3.9.6is the toy server naming itself. If a 403 arrives with aServervalue your application never sends, something in front of it answered.
The mistake this causes
Returning 401 when a signed-in user lacks permission, or 403 when the request simply has no valid credentials.
Why people do it The names invite it: "Unauthorized" reads like a permissions error. But 401 is defined around missing or invalid authentication and must include a WWW-Authenticate challenge, while 403 is the refusal that re-authenticating cannot fix.
What you see Sending 401 for a permission failure makes clients treat it as an expired session: they refresh tokens or bounce the user to login, who signs in successfully and is bounced straight back — a login loop. Sending 403 for missing credentials does the opposite: the client never prompts for sign-in, so the user sees a flat refusal for a page they could have accessed.
If it goes wrong
curl -Ishows a different status from the browser, for exampleHTTP/1.1 501 Unsupported method ('HEAD')-Isends a HEAD request, and a server can handle HEAD differently from GET. Usecurl -ito see the headers and body of a normal GET, orcurl -s -o /dev/null -D - <url>for the headers alone.curl: (22) The requested URL returned error: 401and no body-f(--fail) hides the response body on errors, and the body often says why. Run it again without-f, add-ito seeWWW-Authenticate, or use--fail-with-bodyin scripts that need both the exit code and the server's explanation.- 401 with
error="invalid_token"although you passed a token - The server refused what arrived. Check that the token has not expired and that the shell variable is set: with
TOKENunset,-H "Authorization: Bearer $TOKEN"sends an empty token.curl -vprints theAuthorizationline that was actually sent; redact it before you share the output. - Signed-in users are sent to the login page again and again on one page
- Check the status the permission check returns. If it is 401 for a user whose token is valid, change it to 403 (with
error="insufficient_scope"on a bearer token API), so the client shows a no-access message instead of signing in again. - 403 with or without credentials, and a body your application never produces
- Something in front of the application answered. Compare the
Serverheader and the body with a response you know came from your code, then check the WAF, CDN, load balancer or IP allowlist. The request will be in their logs, not the application's.
How it shows up in production
An API returns 401 whenever a request is refused — missing token, expired token, and also a valid token from a user without the admin role. The front end is built correctly against the spec: on 401, refresh the token and retry; if that fails, send the user to login.
A regular user clicks an admin-only link. The API answers 401. The front end refreshes the token, retries, gets 401 again, and redirects to login. The user signs in, lands back on the same link, and loops.
Support reports it as a broken login, so the investigation starts in the identity provider — which is working perfectly. The tokens are valid; the user is authenticated; the answer was simply the wrong status code for a permission check.
Changing that one branch to 403 fixes it: the front end stops treating the refusal as an authentication problem and shows a no-access message instead.
How to tell which one you are hitting
- Does the response include a WWW-Authenticate header?
- A 401 is required to carry one. A 401 without it is non-conforming. A 403 does not need one under RFC 9110, but a bearer token API adds one with
error="insufficient_scope", so check the status line as well as the header. - Would signing in, or signing in again as the same user, change the outcome?
- If yes, it is an authentication problem and 401 is correct. If the identity is known and simply not permitted, 403.
- Does removing the credentials change the status?
- With no credentials, an application that uses HTTP authentication normally answers 401. If it still says 403, either the refusal is not about identity, so look first at a firewall, IP rule or proxy in front of the application, or the application sends 403 for missing credentials, which is the mistake described on this page.
- Would confirming this resource exists leak something?
- Then RFC 9110 allows responding 404 instead of 403 to hide it — trading a clearer error for not revealing the resource.
Security considerations
- A 403 confirms that the resource exists. If that is itself sensitive, such as another customer's invoice number, RFC 9110 lets the server answer 404 instead. Otherwise anyone can find out which IDs are real by watching for 403 rather than 404.
- Say why in the 403, but not too much. RFC 9110 lets the body explain the refusal and RFC 6750 lets a bearer API name the missing
scope, which helps your own clients. Internal role names, policy rules or other users' details in that body tell an attacker exactly what to go after. - A
Basicchallenge asks the client for a username and password, which are then sent base64-encoded with every request. That is encoding, not encryption: RFC 7617 says Basic SHOULD NOT protect valuable information without HTTPS, because anyone on the path can read the password. - Bearer tokens work for whoever holds them.
curl -v, which this kind of debugging leads to, prints theAuthorizationheader in full, and so does any log that records request headers. Redact before pasting output anywhere, and test with short-lived test tokens: RFC 6750 says clients MUST make sure tokens do not leak to unintended parties.
In an interview
How this gets asked in an interviewpreparation
Usually phrased as What is the difference between 401 and 403?
What a strong answer contains 401 is authentication: the request lacks valid credentials, and the server must send a WWW-Authenticate challenge saying how to authenticate. 403 is refusal after the server understood the request — for example an authenticated identity without permission, but possibly unrelated to credentials entirely. A strong answer notes the misleading name of 401, explains that clients react differently — re-authenticate on 401, do not blindly retry a 403 — and mentions that a server may send 404 instead of 403 to hide a resource's existence.
The follow-up "Users get stuck in a login loop on one page. What would you check?" Whether a permission failure is returning 401, which tells the client to re-authenticate even though the user already is.
Sources
The behaviour this page describes, as documented by the projects themselves.
Did this get you to an answer?
No text box on purpose — please do not paste production logs anywhere
Something wrong or out of date? Report it