Debugging 401 Unauthorized With JWTs: 7 Causes
By OpsAtlas · · 3 min read
Table of contents
The login works, the frontend has a token, and the API returns 401 Unauthorized with no explanation. Most APIs say nothing about why a token was rejected, which is the right call for security but makes debugging slow. The cause is almost always one of seven things, and you can check six of them by reading the token.
First, decode the token. Paste it into the JWT decoder. It shows the header and claims and converts the timestamps to readable dates. It runs entirely in your browser and never puts the token in the URL. Then go through the list.
1. It expired
Compare exp with the current time. Access tokens commonly last 5 to 60 minutes. If yours expired, the bug is in the client: the refresh flow didn’t run, or it ran and the old token stayed cached.
A related trap is exp in milliseconds. 1767229200000 read as seconds is tens of thousands of years in the future. Some libraries accept it as “never expires”, others reject it as invalid. The decoder warns about it.
2. Clock skew
The token was issued a second ago, but the API says it is not valid yet. That happens when the API server’s clock is behind the issuer’s and the token has an nbf (not before) or a recent iat. Most libraries allow a 30–60 second tolerance. Some default to zero. Check the clocks with date -u on both machines, and make sure NTP or chrony is running. Containers use the host’s clock.
3. Wrong audience
aud says who the token is for. If it says api.example.com and the API expects https://api.example.com/, the check fails: the comparison is an exact string match, so the scheme and the trailing slash count. Multi-API setups often send a token issued for one API to another. Check the audience your identity provider was asked for.
4. Wrong issuer
Same story with iss. Look for a trailing slash, http instead of https, a tenant ID in the URL, or a token from the staging identity provider sent to production. Compare the decoded iss character by character with the API’s configured issuer.
5. An ID token instead of an access token
OpenID Connect returns two tokens, and they look alike. The ID token tells the client who logged in. Its aud is the client ID. The access token is the one to send to APIs. Sending the ID token in Authorization: Bearer is a very common mistake, and the decoded aud makes it obvious: if it is your frontend’s client ID, you sent the wrong token.
6. The key rotated
The header’s kid identifies the key that signed the token. After the identity provider rotates its keys, an API that cached the old key set can’t find the new kid and rejects every new token. Meanwhile, old tokens still work, which makes the problem look random. Compare the token’s kid with the provider’s JWKS:
curl -s https://auth.example.com/.well-known/jwks.json | jq -r '.keys[].kid'
If the kid is missing from the API’s cached copy, fix the cache so it refreshes the key set when it sees an unknown kid.
7. The algorithm doesn’t match
The API should pin the algorithm it expects, for example RS256. A token signed with HS256, for instance by a test helper using a shared secret, is correctly rejected. A header with alg: none must always be rejected. If yours is accepted, that is a vulnerability, not a fix. The decoder flags none and other risky header fields such as jku.
The one you can’t see in the token
If all seven checks pass, look at the request itself:
curl -i https://api.example.com/me -H "Authorization: Bearer $TOKEN"
- The header must be
Authorization: Bearer <token>, with one space and no quotes. A stray newline at the end of$TOKENis surprisingly common when the value comes from a file. - A proxy, API gateway or CDN may strip or rename the
Authorizationheader before it reaches the app. - CORS preflight (
OPTIONS) requests carry no token. If the API requires auth onOPTIONS, browsers report a CORS error instead of a 401.
Make the next one faster
- Log why the API rejected a token, such as
expired,aud mismatchorunknown kid, on the server only, never in the response. - Pass the issuer, audience and algorithms explicitly to your JWT library instead of relying on its defaults.
- Keep tokens out of logs, URLs and error trackers. A decoded payload often contains email addresses and roles.
Decode first, then check exp, nbf, aud, iss, the token type, kid and alg. That covers most 401s before you open the server code.
Try it yourself
JWT decoder
Decode a JSON Web Token in your browser: header, payload, exp/nbf/iat as dates, and warnings for alg none, long lifetimes and leaked secrets.
Security