401 vs 403: diagnosing API authentication failures
A 401 means the API does not know who you are. A 403 means it knows and is refusing anyway. Telling them apart points you at completely different fixes.
Two status codes, constantly confused, pointing at entirely different problems:
HTTP/2 401
www-authenticate: Bearer realm="api", error="invalid_token"HTTP/2 403
content-type: application/json
{"error": "insufficient_scope", "required": "read:analytics"}Which one you received determines where to look. Treat them as interchangeable and you will spend an hour regenerating a key that was never the problem.
Short answer
401 means the API does not know who you are. Credentials were missing, malformed, expired or wrong. 403 means the API knows exactly who you are and is refusing anyway. Your identity is fine; your permissions, plan or scope are not. Regenerating a key fixes 401s. It almost never fixes 403s.
The naming is genuinely wrong
The confusion is not your fault. HTTP names 401 as "Unauthorized" when it actually signals a failure of authentication. RFC 9110 admits this in the spec text itself, noting that 401 concerns authentication while 403 concerns authorisation.
Substitute the accurate words and it becomes obvious:
| Code | Official name | What it actually means | The question it answers |
|---|---|---|---|
| 401 | Unauthorized | Unauthenticated | Who are you? |
| 403 | Forbidden | Unauthorised | You are X, and X cannot do this |
A 401 is a bouncer who cannot find your name on any list. A 403 is a bouncer who found your name, and your name is on a different list.
Diagnosing a 401
The API did not accept your credentials. In practice this is nearly always one of six things, listed roughly by how often they turn out to be the cause.
1. The key never arrived
More common than a wrong key. Print exactly what you are sending:
const key = process.env.API_KEY;
console.log('length:', key?.length);
console.log('first 4:', key?.slice(0, 4));
console.log('has whitespace:', key !== key?.trim());If the length is undefined, the environment variable is not loaded. In Next.js, a variable without the NEXT_PUBLIC_ prefix is unavailable in client components, which produces a 401 that only appears in the browser.
2. Trailing whitespace
Reading a key from a file or pasting from a terminal often carries a newline:
// A trailing \n makes this header invalid.
headers: { Authorization: `Bearer ${key}` }
// Trim at the boundary, once.
headers: { Authorization: `Bearer ${key.trim()}` }3. The wrong scheme
Bearer, Basic, Token and ApiKey are not interchangeable, and the header is case-sensitive about the value:
Authorization: Bearer sk_live_abc123
Authorization: Basic dXNlcjpwYXNz
Authorization: Token abc123
X-API-Key: abc123Read the documentation for the exact form. Some APIs want the key in a query parameter instead, and sending it in a header there yields a 401 no matter how valid the key is.
4. The token expired
OAuth access tokens are short-lived, often an hour. A request that worked this morning and fails this afternoon with no other change is almost certainly this. You need the refresh flow, not a new key.
5. Wrong environment
Test keys against production endpoints, or the reverse, return 401. A key prefixed sk_test_ sent to a live endpoint will not work.
6. The key was rotated or revoked
If someone regenerated the key in a dashboard, every copy elsewhere died at that moment. Check the dashboard before debugging your code.
Diagnosing a 403
Your identity was accepted. Something about what you are permitted to do was not. Regenerating credentials is almost never the answer.
Missing scope
OAuth tokens carry a set of scopes. A token granted read:user cannot write, and the failure appears only when you call the endpoint that needs more:
{
"error": "insufficient_scope",
"scope": "repo:write"
}The fix is to re-run the authorisation flow requesting the additional scope. Existing tokens cannot gain scopes.
Plan restrictions
Free tiers commonly authenticate fine and then refuse specific endpoints. Historical data, bulk export and higher-resolution results are the usual ones. The response often says so:
{ "error": "This endpoint requires a Pro subscription." }Origin, referrer or IP restrictions
Keys can be locked to specific referrers or IP addresses. A key restricted to yourapp.com returns 403 from localhost, which produces the maddening pattern of working in production and failing in development. Check the key's restrictions in the provider's dashboard.
Missing a required header
Some APIs require an identifying User-Agent and return 403 without one. The US National Weather Service is a well-known example: it is entirely free and needs no key, but it refuses anonymous clients.
The resource is not yours
Requesting a record belonging to another account returns 403 on a well-built API. Your credentials are valid, the resource exists, and it is not yours.
A diagnostic you can run in a minute
# 1. Does the endpoint work with no credentials at all?
curl -s -o /dev/null -w "no auth: %{http_code}\n" \
https://api.example.com/public
# 2. Does your key authenticate anywhere?
curl -s -o /dev/null -w "with key: %{http_code}\n" \
-H "Authorization: Bearer $API_KEY" \
https://api.example.com/me
# 3. What does the failing endpoint actually say?
curl -s -D - \
-H "Authorization: Bearer $API_KEY" \
https://api.example.com/the-failing-endpointRead it like this:
- Step 2 returns 401 → the key is the problem. Work through the six causes above.
- Step 2 returns 200, step 3 returns 403 → the key is fine. This is scope, plan or ownership.
- Step 3 returns 401 while step 2 returned 200 → the endpoint needs a different credential, such as a separate token for that product.
The WWW-Authenticate header on a 401 usually names the reason directly. It is the single most useful header in this whole process, and almost nobody reads it.
Sidestepping the problem entirely
For prototypes, demos and teaching material, the fastest fix is often to remove authentication from the equation.
Our no-key collection lists them, and every one is health-checked daily.
When the code is wrong and the body is right
A practical complication: plenty of APIs do not follow the distinction, and your client has to cope regardless.
Some return 403 for everything authentication-related, including a missing credential. Some return 401 for a permission failure because the developer read "Unauthorized" literally. A few return 404 for a resource you are not permitted to see — which is deliberate rather than sloppy, since confirming that something exists is itself a disclosure, and this is standard practice for private repositories and similar.
The body usually disambiguates even when the status does not. An error message mentioning a token, a signature or an expiry is an authentication problem whatever the code says. One mentioning scope, permission, plan or role is an authorisation problem. And the WWW-Authenticate header, when present, is a strong signal of a genuine 401, since it exists specifically to tell the client how to authenticate.
The safe client behaviour is to treat the status as the primary signal, then refine on the body where the distinction changes what you do. The action that matters is binary: is it worth trying again with a different credential, or not? A 401 says the credential is the problem, so refreshing a token and retrying once is reasonable. A 403 says it is not, so retrying with the same identity will fail identically and the loop is pure waste.
One specific trap: retrying a 401 in a loop without refreshing anything is a good way to trigger an account lockout or a rate limit, turning a recoverable problem into a longer outage. Refresh once, retry once, then stop and surface it.
Telling a user what to do about it
The two codes need genuinely different messages, and the generic "access denied" serves neither.
A 401 means the person can fix this. Their session expired, their token is stale, they signed out elsewhere. The message should say so and give them the action: sign in again. Automatically redirecting to a login screen is usually right, provided you preserve where they were going.
A 403 means they cannot fix it themselves. They are correctly identified and lack permission, so offering a sign-in link is actively unhelpful — they are already signed in, and trying again changes nothing. What helps is saying what is missing and who can grant it: an administrator, an upgraded plan, a different account.
The difference is worth the small effort because getting it backwards produces a specific frustration. A user shown a login prompt who is already logged in concludes the application is broken, and a user told to contact an administrator when their session merely expired contacts one unnecessarily.
If you are designing an API
Be precise, because your users are debugging against your choices:
- 401 when credentials are absent, malformed or invalid. Always include
WWW-Authenticate. - 403 when credentials are valid but insufficient. Say what is missing.
- 404 instead of 403 when merely confirming a resource exists would leak information.
A 403 that says {"error": "Forbidden"} and nothing else forces every one of your users through the entire checklist above. One extra sentence in that body saves thousands of hours across your user base.
Common questions
What is the difference between 401 and 403?
A 401 means authentication failed or was not supplied, so the API does not know who you are. A 403 means authentication succeeded but you are not permitted to do this, so the API knows who you are and is refusing anyway. One is an identity problem, the other a permission problem.
Why does the spec call 401 'Unauthorized' when it means unauthenticated?
It is a naming mistake preserved for compatibility. RFC 9110 acknowledges it directly, noting that 401 is about authentication while 403 is about authorisation. Read 401 as 'unauthenticated' and the confusion disappears.
Can a 403 ever mean my key is wrong?
Yes. Some APIs return 403 for an invalid key instead of 401, which is technically incorrect but common. If you get a 403 on an endpoint that should be available to you, test your key against a known-public endpoint before assuming it is a permissions issue.
My key works in curl but returns 401 in my app. Why?
Nearly always the key is not arriving as you think. Common causes are a trailing newline from reading a file, an unexpanded environment variable placing the literal string in the header, or a proxy stripping the Authorization header.
Should my own API return 401 or 403 for an unknown user?
Return 401 with a WWW-Authenticate header when credentials are missing or invalid. Return 403 when they are valid but insufficient. For resources whose existence is itself sensitive, 404 is often the better choice.
Sources
Written by
SandyI build and run this site on my own: the crawler that assembles the catalogue, the checker that probes every listing, and the writing. Before this I built SaveFromInternet and GrabReels, which meant living with other people’s APIs full time — parsers breaking when a platform shipped a change, rate limits arriving without warning, endpoints disappearing overnight. This directory exists because I got tired of free API lists that had never been checked.