HTTP 401 vs 403: What Is the Difference?

401 Unauthorized means the request carries no valid credentials: the server does not know who you are, and it says how to authenticate in the WWW-Authenticate header. 403 Forbidden means the server knows who you are and refuses anyway. Fix a 401 with credentials. A 403 will not change until the permission does.
Both sit in the 4xx group of HTTP status codes, and teams swap them constantly. This page covers what each specification actually requires, which headers each response has to carry, the framework defaults that blur the two, and how to test them apart.
401 vs 403 at a glance
| 401 Unauthorized | 403 Forbidden | |
|---|---|---|
| Question it answers | Who are you? | You may not do this |
| Credentials | Missing or invalid | Valid but insufficient, or irrelevant |
| Required header | WWW-Authenticate with at least one challenge | None required by RFC 9110 |
| Client retry | Once, with new or refreshed credentials | Not with the same credentials |
| Typical fix | Send or refresh the token | Grant the permission or change the action |
Sources for the table: RFC 9110 section 15.5.2 and section 15.5.4. The bearer token profile adds a challenge header to some 403 responses too, which the next section covers.
What the RFC says about 401 and 403
For 401, RFC 9110 section 15.5.2 says the request "has not been applied because it lacks valid authentication credentials for the target resource." The same section requires the server to send at least one applicable challenge in WWW-Authenticate. It also says a client that already sent credentials and gets a 401 again should show the response rather than loop.
For 403, section 15.5.4 says "the server understood the request but refuses to fulfill it." Credentials may be valid and still insufficient, or the refusal may have nothing to do with credentials at all.
Bearer tokens narrow this further. RFC 6750 section 3.1 puts an expired, revoked, malformed, or otherwise invalid token under 401, and a valid token without the required scope under 403. That single rule settles most arguments in API authentication code review.
What each response must carry
A 401 must include WWW-Authenticate. The client answers it in the Authorization header, per RFC 9110 section 11.6.2. A 403 needs no challenge header under RFC 9110, but under the bearer profile a protected resource must send one when the supplied token does not enable access, carrying error="insufficient_scope" and the scope required.
Neither response is heuristically cacheable, and a shared cache cannot reuse a response to a request that carried Authorization unless a directive such as public, must-revalidate, or s-maxage permits it (RFC 9111 sections 3, 3.5, and 4.2.2). Sending Cache-Control: no-store is our recommendation drawn from those two rules, not a requirement written into either one.
Bodies below use problem details, the JSON error format from RFC 9457, where type is a URI you define for the class of problem. First, an anonymous request:
GET /v1/admin/reports HTTP/1.1
Host: api.example.com
Accept: application/json
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api"
Content-Type: application/problem+json
Cache-Control: no-store
{
"type": "https://api.example.com/problems/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Provide a valid bearer token."
}
Now the same endpoint with a token that authenticates a real member who lacks the permission:
GET /v1/admin/reports HTTP/1.1
Host: api.example.com
Authorization: Bearer <valid-member-token>
Accept: application/json
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer realm="api", error="insufficient_scope", scope="reports:read"
Content-Type: application/problem+json
Cache-Control: no-store
{
"type": "https://api.example.com/problems/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "The authenticated user lacks the reports:read permission."
}
401, 403, 404, 407, and 429: which one?
| Code | Use it when | Source |
|---|---|---|
| 401 | The request has no valid credentials for this resource | RFC 9110 section 15.5.2 |
| 403 | The server understood and refuses; identity is known or irrelevant | RFC 9110 section 15.5.4 |
| 404 | The resource is absent, or the server prefers 404 over 403 to hide that a forbidden resource exists. Unlike the other two, a 404 is heuristically cacheable | RFC 9110 section 15.5.5 |
| 407 | The proxy, not the origin, wants credentials, using Proxy-Authenticate and Proxy-Authorization | RFC 9110 section 15.5.8 |
| 429 | Too many requests in a time window. A rate limit, not an identity decision. May carry Retry-After, and must not be cached | RFC 6585 section 4 |
Common causes and how to fix them by stack
| Response | Cause | Fix |
|---|---|---|
| 401 | No Authorization header, or credentials that do not authenticate | Send the scheme named in WWW-Authenticate. For a bearer API that is Authorization: Bearer <token>. |
| 401 | Token expired, revoked, or malformed | Refresh or reacquire it, retry once, and stop replaying the token that was rejected. |
| 403 | Authenticated, but missing a role, scope, tenant, or resource permission | Grant the permission, use a principal that already has it, or change the action. Never retry unchanged. |
| 403 | A gateway or firewall policy block, with valid application credentials | Find the matched rule and narrow it. Do not remap the response to 401 to make clients retry. |
| 403 from AWS API Gateway | Missing route or method, invalid API key, bad request signature, or an expired auth token | Check the deployed resource and method first, then the key or signing credentials the gateway response names. |
Sources: RFC 9110 section 11.6.2, RFC 6750 sections 2.1 and 3.1, and the AWS gateway response type list, which maps WAF_FILTERED to 403.
Express
Two mistakes account for most of it: mounting a protected router before the auth middleware, and one handler that maps every failure to the same code. Mount authentication first, challenge from inside it, and keep the role check in a separate layer that answers 403.
function requireAuth(req, res, next) {
const user = verifyBearer(req.get('Authorization'));
if (!user) {
return res
.set('WWW-Authenticate', 'Bearer realm="api"')
.status(401)
.type('application/problem+json')
.json({ type: 'https://api.example.com/problems/unauthorized', title: 'Unauthorized', status: 401 });
}
req.user = user;
next();
}
function requireScope(scope) {
return (req, res, next) => {
if (!req.user.scopes.includes(scope)) {
return res
.set('WWW-Authenticate', 'Bearer realm="api", error="insufficient_scope", scope="' + scope + '"')
.status(403)
.type('application/problem+json')
.json({ type: 'https://api.example.com/problems/forbidden', title: 'Forbidden', status: 403 });
}
next();
};
}
app.use('/v1/admin', requireAuth, requireScope('reports:read'), adminRouter);
Django REST Framework
SessionAuthentication returns 403 for a denied unauthenticated request, so a missing login arrives looking like a permission failure. Put BasicAuthentication or TokenAuthentication first in authentication_classes so the framework can issue a challenge, require IsAuthenticated, and reserve a permission class such as IsAdminUser for the genuine 403 case.
Spring Security
A custom JSON error handler that treats AuthenticationException and AccessDeniedException the same way collapses the two codes into one. Register an AuthenticationEntryPoint for the 401 path, including the challenge header, and a separate AccessDeniedHandler for 403. The ExceptionTranslationFilter already routes each exception to the right one.
AWS API Gateway
A broad custom DEFAULT_4XX mapping blurs authentication failures, authorizer denials, missing routes, and firewall blocks into one indistinguishable response. Keep UNAUTHORIZED as 401, ACCESS_DENIED as 403, and WAF_FILTERED as 403, each with its own template.
How a client should handle 401 and 403
On a 401, read the challenge, obtain or refresh credentials, and retry once with a new
Authorizationheader. If the same challenge comes back, stop and surface the error.On a 403, do not retry with the same token. Hide or disable the action, explain which permission is missing, or start an access request. Retry only after the credentials or the permission state have actually changed.
Keep the two paths separate. A handler that refreshes tokens on a 403 loops forever, because a fresh token has exactly the same scopes as the one it replaced.
How to test 401 and 403 responses
Test four identities, separately. Anonymous gets 401. An invalid token gets 401. A valid member without the scope gets 403. The authorized caller gets the success code. Do not weaken the assertion to "401 or 403": that lets an authentication bug and an authorization bug pass as the same result. The rest of API testing applies as usual.
import assert from "node:assert/strict";
const endpoint = process.env.API_BASE_URL + "/v1/admin/reports";
const anonymous = await fetch(endpoint, {
headers: { Accept: "application/json" },
});
assert.equal(anonymous.status, 401);
assert.match(anonymous.headers.get("www-authenticate") ?? "", /^Bearer\b/i);
assert.match((await anonymous.json()).type, /\/unauthorized$/);
const forbidden = await fetch(endpoint, {
headers: {
Accept: "application/json",
Authorization: "Bearer " + process.env.MEMBER_TOKEN,
},
});
assert.equal(forbidden.status, 403);
assert.match((await forbidden.json()).type, /\/forbidden$/);
The flaky version shares one account across parallel tests while another test changes its role or invalidates its session. The same request then alternates between 2xx, 401, and 403 depending on which test ran first. Fix it with a fixed anonymous case, a freshly minted invalid token, separate member and admin accounts, and, where tests mutate server state, one account per parallel worker. That is the usual cure for flaky tests in an auth suite. While you are there, cover the role case from broken function level authorization, where an admin route answers a member token with 200.
How Qodex flags 401 and 403
Qodex generates API tests from an OpenAPI spec, a Postman collection, or a chat brief, and the import infers the auth scheme, so the negative cases it writes include the no-token and wrong-scope calls. Those run on every pull request. An endpoint that answers 403 where the contract says 401 fails with the response attached, challenge header included, and the failure is classified as a real bug rather than a flake. See how Qodex API testing works.
Related status codes
HTTP 429 Too Many Requests. The limit that clears with time.
HTTP 422 Unprocessable Content. Valid syntax that fails a rule.
HTTP 500 Internal Server Error. The generic server failure.
HTTP 503 Service Unavailable. Overload and maintenance.
HTTP status codes. The full list, 1xx to 5xx.
API testing. What to assert and how to run it in CI.
Frequently Asked Questions
What is the difference between 401 and 403?
A 401 says the server has no valid credentials for you and sends a challenge, so retry with credentials. A 403 says the server knows who you are, or does not care, and refuses the request anyway.
Should an expired token return 401 or 403?
401. RFC 6750 section 3.1 puts expired, revoked, and malformed tokens under 401. A 403 is for a valid token that does not carry the scope the endpoint needs, which is a different failure.
Does every 401 response need a WWW-Authenticate header?
Yes. RFC 9110 section 15.5.2 requires at least one applicable challenge on every 401. A 401 without it leaves the client with no way to work out how to authenticate.
Should a client retry a 401 or 403 response?
Retry a 401 once, with new or refreshed credentials. Do not retry a 403 with the same credentials. Change the permission, change the action, or surface the failure to the person using the client.
When should an API return 404 instead of 403?
When admitting the resource exists is itself a leak. RFC 9110 section 15.5.4 allows a server to answer 404 in place of 403 to hide that a forbidden resource is there at all.
What is the difference between 401 and 407?
A 407 comes from a proxy and uses Proxy-Authenticate and Proxy-Authorization. A 401 comes from the origin server and uses WWW-Authenticate and Authorization. The mechanics match; the party asking for credentials does not.
What is the difference between 403 and 429?
A 403 is a permission or policy refusal that waiting will not fix. A 429 is a rate limit that clears once the window resets, and it may carry a Retry-After header telling you how long to wait.
Can 401 and 403 responses be cached?
Not by default. Neither is heuristically cacheable, and a shared cache needs an explicit directive before it may store a response to a request that carried an Authorization header. Send Cache-Control no-store.



