Authorization · Information security · 6 min read
How to Validate a JWT Access Token: The Checks in Order
Short answer
To validate a JWT access token, parse it without trusting it, accept only the algorithms you expect, pick the verification key by issuer and kid from that issuer's published keys, verify the signature, then check iss, aud, exp, nbf and the token type before you look at scopes or claims; reject the token if any single check fails.
Key takeaways
- Decoding is not validating: anyone can read a JWT, so nothing in it counts until the signature has been verified.
- Fix the accepted algorithms in configuration; never let the token's alg header choose how it is verified, and never accept none.
- Take the signing key from the expected issuer's JWKS, selected by kid, not from URLs or keys embedded in the token.
- Check iss, aud, exp and nbf on every request, with a small clock-skew allowance of a minute or two.
- RFC 9068 access tokens carry typ at+jwt, which stops an ID token from being replayed as an access token.
A JWT arrives in the Authorization header and your API has a few milliseconds to decide whether to trust it. Most real-world JWT vulnerabilities come from skipping a check or doing them in the wrong order: trusting the alg header, fetching a key from a URL the attacker supplied, or forgetting to check the audience. This article lists the checks an API should run, in order, with the reason for each.
What is the difference between decoding and validating a JWT?
Decoding reads the header and payload, which anyone can do; validating proves the token was issued by a trusted party for your API and is still current.
A JWS-signed JWT is three base64url segments: header, payload, signature. Decoding the first two takes one line of code and needs no key. That is why a JWT decoder can show you any token's claims. It is also why nothing in a decoded token means anything until the signature checks out against a key you trust.
Treat the decoded header and payload as untrusted input, the same as a query string.
Which algorithms should you accept?
Accept only the algorithm or short list of algorithms your issuer actually uses, set in your configuration, and reject everything else, including none.
RFC 8725, the JWT best current practices, asks libraries to let applications specify the algorithms they accept and to reject tokens that use any other. Two classic attacks follow from ignoring that:
- With
alg: none, the attacker strips the signature and declares the token unsigned. A library that honors the header accepts it. - In algorithm confusion, the issuer signs with RS256. The attacker sends a token marked HS256 and signs it with the issuer's public key as the HMAC secret. A library that picks the algorithm from the header and the key from configuration may verify it.
The fix for both is the same. Your code says "RS256 only" (or ES256, or whatever your issuer uses), and the token's alg must match that list exactly.
Where should the verification key come from?
Get the key from the expected issuer's published key set, selected by the token's kid, and never from a URL or key embedded in the token.
Most authorization servers publish their public keys as a JWK Set at a jwks_uri, which you find in the issuer's metadata. Your API should:
- Know which issuers it trusts, in configuration.
- Fetch each issuer's JWKS from that issuer's own metadata, over HTTPS, and cache it.
- Pick the key whose
kidmatches the token header. - If no key matches, refresh the cache once (keys rotate), then reject.
Ignore jku, x5u and embedded jwk headers unless you have a strict allow-list for them. An attacker who controls those can point you at their own key.
What order should the checks run in?
Verify the signature first, then the claims, then the authorization decision, and stop at the first failure.
| Step | Check | Fail if |
|---|---|---|
| 1 | Structure | Not three segments, or header and payload aren't valid JSON |
| 2 | alg |
Not in your allow-list |
| 3 | typ |
Not at+jwt (when your issuer follows RFC 9068) |
| 4 | Key | No trusted key for this issuer and kid |
| 5 | Signature | Doesn't verify with that key |
| 6 | iss |
Not exactly the expected issuer string |
| 7 | aud |
Doesn't contain your API's identifier |
| 8 | exp, nbf |
Expired, or not yet valid (with small skew) |
| 9 | Scopes, roles | Don't permit this operation |
Checking iss before signature verification is fine for picking the key, as long as you treat it as a hint and still require the signature to verify against that issuer's key.
Why does the audience check matter so much?
Without an audience check, a token issued for one API can be replayed against another API that trusts the same issuer.
If your company runs an orders API and a billing API behind one authorization server, a token minted for orders must not open billing. RFC 9068 requires the aud claim in JWT access tokens and requires resource servers to check that it contains the identifier they expect. Use a specific value, such as https://api.example.com/billing, not a generic one shared by every service.
How should you handle expiry and clock skew?
Reject tokens past exp or before nbf, allowing only a small clock skew of a minute or two.
Servers' clocks drift slightly, so most libraries let you set a leeway. Keep it small. Each extra minute of leeway is an extra minute a stolen token keeps working. Also be wary of very long-lived access tokens. Short lifetimes with refresh tokens limit the damage of a leak far more than any check you can add later.
What does the typ header protect against?
Requiring typ at+jwt stops an attacker from presenting an ID token, or another JWT from the same issuer, as an access token.
The same authorization server often signs ID tokens and access tokens with the same key. Both have a valid signature, an issuer and an audience. RFC 9068 defines the at+jwt media type for access tokens so resource servers can tell them apart. If your issuer sets it, require it. If it doesn't, check whether it can, and in the meantime make sure your audience values for APIs never match a client ID.
What should you log when a token fails?
Log which check failed, the issuer, kid and a request ID, but never the full token.
A full JWT in a log file is a working credential until it expires. Log enough to debug: "aud mismatch, iss=https://login.example.com, kid=2026-10" is plenty. Return a plain 401 with a WWW-Authenticate header to the caller and keep the reason out of the response body.
To see what a given token actually contains while you debug, paste it into the Twobo JWT decoder. It runs in your browser and shows the header, claims and expiry in readable form. Remember that it decodes, and your API still has to validate.
Questions
Is decoding a JWT the same as validating it?
No. Decoding only base64url-decodes the header and payload, which anyone can do. Validation means verifying the signature with a trusted key and checking the claims; until then the contents are untrusted input.
Which JWT claims must an API check?
At minimum the issuer (iss), audience (aud), expiry (exp) and, if present, not-before (nbf). For access tokens following RFC 9068, also check the typ header and the scope or authorization claims your endpoint needs.
How much clock skew should I allow?
A small allowance, commonly up to a minute or two, covers normal clock drift between servers. Larger values extend the life of stolen tokens.
Should an API call the authorization server to check every JWT?
Not usually. Self-contained JWTs are designed to be validated locally with cached keys. If you need immediate revocation, use short token lifetimes or token introspection (RFC 7662) for sensitive operations.
What is the alg none attack?
An attacker changes the header to alg none and removes the signature. A library that honors the header would accept the unsigned token. Allow-listing algorithms in your own configuration closes this.
Sources
Tagged: JWT, OAuth 2.0, API, Security, Authorization