By AndyPublished
How to Debug a JWT: A Step-by-Step Routine for 401 and 403 Errors
Step 1: Capture the Token That Was Actually Sent
Debug the token on the failing request, not the one you think the client is using. Tokens inlocalStorage or a cookie are often a refresh behind, and single-page apps frequently hold both an ID token and an access token.
- ·Browser: DevTools → Network → select the failing request → Headers →
Authorization. Copy the value afterBearer. - ·Command line: repeat the call with
curl -vand read the> Authorization:line that curl printed. - ·Server side: log the first and last eight characters of the token and its length, never the whole token, then compare with what the client sent.
While you are there, confirm the header itself is right: the scheme must be Bearerfollowed by one space, with no quotes around the token and no trailing newline. A surprising share of "invalid token" reports end at this step.
Step 2: Check the Structure
Paste the token into a decoder. A signed JWT (JWS, RFC 7515) in compact form has exactly three Base64url segments separated by dots. What you see tells you which branch you are on:
- ·Three segments that decode to JSON: a normal signed JWT. Carry on.
- ·Five segments: an encrypted JWT (JWE, RFC 7516). The payload cannot be read without the recipient's private key, so a decoder can only show the header.
- ·No dots at all: an opaque access token. It is not a JWT; the API has to validate it by calling the provider's introspection endpoint (RFC 7662). Some providers only issue JWT access tokens when an API audience or resource is requested at login.
- ·Segments that will not decode: the token was truncated, URL-encoded twice, or had whitespace pasted into it. See malformed tokens.
Step 3: Read the Header: alg, kid and typ
{
"alg": "RS256",
"kid": "2026-09-a1",
"typ": "at+jwt"
}- ·
algmust be in the verifier's allow-list. A token signed withRS256sent to a service configured forHS256(or the reverse) fails as "invalid algorithm" or as a signature failure, depending on the library. A token with"alg": "none"must always be rejected; see the none algorithm vulnerability. - ·
kidnames the key that signed the token. Open the provider's JWKS URL (usually listed asjwks_uriin/.well-known/openid-configuration) and confirm a key with that exactkidis published. If it is not, the token came from another tenant or environment, or the verifier is holding a stale key set. - ·
typisJWTfor most tokens. OAuth access tokens that follow RFC 9068 useat+jwt, and strict validators reject an ID token presented where an access token is expected on this basis.
Step 4: Check the Time Claims
exp, nbf and iat are NumericDate values: seconds since 1970-01-01 UTC (RFC 7519 §2). Compare them with the current Unix time on the verifying machine, not your laptop.
date -u +%s # current Unix time in seconds # exp must be greater than now; nbf (if present) must be less than or equal to now
The milliseconds bug
A 13-digit exp such as 1790000000000 was written in milliseconds, usually from Date.now() without dividing by 1000. Most libraries accept it and treat the token as valid for tens of thousands of years, which hides the bug until a stricter verifier rejects it. Fix the issuer.
Clock skew
If the token fails on one server and passes on another, compare their clocks. A verifier running a few minutes fast rejects fresh tokens as expired; one running slow rejects them as not yet valid. Make sure NTP is running and allow a small leeway, typically 30 to 60 seconds, in the verifier's configuration. The clock skew section covers per-library settings.
Step 5: Match iss and aud Exactly
iss and aud are compared as exact strings. Trailing slashes, http versus https, and a tenant or region segment in the URL all count.
- ·Issuer: compare the token's
isswith the value your API is configured to expect, character for character. Staging and production usually differ. - ·Audience:
audmay be a string or an array. The API's identifier must appear in it. - ·ID token sent to an API: in OpenID Connect the ID token's
audis the client ID of the application that logged in, not your API. Ifaudlooks like a client ID, the client is sending the wrong token; it should send the access token.
Step 6: Verify the Signature With the Right Key
Only now check the signature. Everything above can be read without a key, and a signature failure is much easier to explain once you know which key and algorithm the token expects.
- ·HS256/384/512: the secret must be the same bytes on both sides. Some providers display the secret base64-encoded; if the issuer used the decoded bytes and the verifier uses the text as shown (or the reverse), every signature fails. Per-environment secrets are the other common cause.
- ·RS256 and PS256: both use an RSA key pair, but the padding differs. A token signed with
PS256will not verify asRS256. See PS256 vs RS256. - ·ES256: JWT requires the raw 64-byte R‖S signature, not DER. Signers that emit DER produce tokens that no standards-compliant verifier accepts. See ES256 explained.
- ·Key rotation: if failures began when the provider rotated keys, the verifier is probably caching the JWKS and not re-fetching when it meets an unknown
kid.
Step 7: If It Verifies, Check Authorisation
A token that passes every check above is authentic and current. If the API still refuses it, the problem is what the token is allowed to do. A 403 usually means exactly this; a 401 at this stage usually means the middleware is not reading the token you think it is.
- ·Scopes: RFC 9068 uses a space-separated
scopestring; some providers use anscparray instead. Check which one your policy reads. - ·Roles and groups: these are private claims, often namespaced (for example
https://example.com/roles). A missing namespace or a renamed claim looks like "user has no role". - ·Header size: large tokens full of group claims can exceed header limits. nginx's default
large_client_header_buffersallows 8 KB per header line and Node.js defaults to 16 KB for all headers; beyond that the request fails with 400 or 431 before your code runs.
Symptom to Likely Cause
| Symptom | Most likely cause |
|---|---|
| 401 on every request, even with a fresh login | Wrong token sent (ID token instead of access token), or the "Bearer " prefix is missing |
| 401 only on some servers or pods | Clock skew on one host, or a stale JWKS cache after key rotation |
| 401 a few minutes after login | Short exp with no working refresh, or exp issued in milliseconds on one side |
| Works locally, fails in production | iss or aud differs between environments; signing secret differs per environment |
| Signature fails after the provider rotated keys | Verifier cached the old JWKS and does not re-fetch on an unknown kid |
| 403 with a token that verifies | Authorisation, not authentication: missing scope or role claim, or the claim has a different name |
| 431 or 400 before your code runs | Token too large for the proxy or server header limit |
Debugging Production Tokens Safely
A live access token is a bearer credential: whoever holds it can use it until it expires. Treat it like a password while you debug.
- ·Use a decoder that runs locally. You can check any online tool yourself: open DevTools → Network, paste a token, and confirm no request carries it; or load the page, go offline, and see whether decoding still works. See decoding a JWT without a server.
- ·Never paste tokens into tickets, chat or logs. Share the decoded header and non-sensitive claims instead.
- ·Prefer an expired token or a token from a test tenant when you only need to see its shape.
- ·If a token has been exposed, revoke the session or refresh token that produced it rather than waiting for
exp.
Summary
Debug a JWT in the verifier's order: capture the token that was really sent, confirm it is a three-part JWS, read alg and kid, check the time claims against the server's clock, match iss and aud exactly, verify the signature with the key the header names, and only then look at scopes and roles. If you have an exact error message, the error lookup table maps it to its cause.