"The library validates the JWT" can mean very different things. The specification that defines the format describes a validation procedure that covers structure and signature, and then stops. Everything that makes a token acceptable here, right now, for this purpose is left to the application.
What the spec's validation steps do not include
RFC 7519, section 7.2 ("Validating a JWT") lists ten steps: check the token has the right shape, decode the header, confirm it is valid JSON, work out whether it is signed or encrypted, validate it under those rules, and decode the claims. If a step fails, the token is rejected.
Missing from that list: exp, nbf and aud. Section 4 states that which claims are required is "context dependent" and outside the scope of the specification. The claim definitions do contain processing rules (an expired token must be rejected, a token whose audience does not include the recipient must be rejected), but nothing forces a verification call to apply them unless the application or its library configuration does.
The result is a gap between two sentences that sound identical: "the signature is valid" and "this token should be accepted".
Seven checks, and where they come from
RFC 8725 (BCP 225, February 2020, "JSON Web Token Best Current Practices") turns the known failure modes into concrete rules. The list below maps each one to its source.
| # | Check | Source |
|---|---|---|
| 1 | Pin the accepted algorithms in the application. Libraries must let the caller specify a supported set; the token's own header is not the source of truth. | RFC 8725 §3.1 |
| 2 | If any cryptographic operation fails, reject the whole token. | RFC 8725 §3.3 |
| 3 | Validate the issuer and make sure the key belongs to that issuer. | RFC 8725 §3.8 |
| 4 | Validate the audience. A token for another service must be rejected. | RFC 8725 §3.9, RFC 7519 §4.1.3 |
| 5 | Enforce exp and nbf, with at most a small clock leeway. |
RFC 7519 §4.1.4, §4.1.5 |
| 6 | Use explicit typing, and keep validation rules for different kinds of JWT mutually exclusive, so an ID token cannot be replayed as an access token. | RFC 8725 §3.11, §3.12, §2.8 |
| 7 | Treat claim values as untrusted input when they reach a query or another interpreter. | RFC 8725 §3.10 |
On leeway, RFC 7519 allows "some small leeway" for clock skew and describes it as usually no more than a few minutes. A leeway of an hour quietly turns a 15-minute access token into a 75-minute one.
What this looks like in code
With the jose library for Node.js, each of those checks is an explicit option on the verify call. Their documented meanings:
-
algorithms: acceptedalgheader values -
issuerandaudience: expectedissandaudvalues, and the claim must be present -
typ: expectedtypheader value, and the header must be present -
clockTolerance: clock skew tolerance -
requiredClaims: additional claims that must exist in the token -
maxTokenAge: maximum time sinceiat
import { jwtVerify, importSPKI } from 'jose';
const publicKey = await importSPKI(process.env.JWT_PUBLIC_KEY, 'RS256');
export async function readAccessToken(token) {
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ['RS256'], // chosen by the server, not by the token
issuer: 'https://auth.example.test',
audience: 'orders-api',
typ: 'at+jwt', // access tokens only (RFC 9068 profile)
clockTolerance: '30s', // seconds, not hours
requiredClaims: ['exp', 'sub'] // a token with no expiry is a policy decision
});
return payload;
}
A token without an exp claim has nothing to expire. Requiring the claim, as above, makes that a rejection instead of an accident.
The check the RFC does not cover: ending a session
RFC 7519 does not define revocation or logout. The nearest feature is the jti claim, which can help detect replay of a token, but it is not a revocation mechanism. A signed token stays valid until exp, whatever happens to the account.
That makes the lifetime a design choice, not a detail:
- keep access tokens short enough that waiting for expiry is acceptable
- when "log out everywhere" or "password changed" has to take effect sooner, check a server-side record (a session version, or a deny list keyed by
jti) - if refresh tokens exist, rotate them and test replay of an old one
The last point has its own test plan: refresh token rotation.
A test list that fits in one afternoon
Each row is a request an authorised tester sends to their own service, with the expected result.
| Token presented | Expected |
|---|---|
| Valid token | 200 |
Same token after its exp
|
401 |
| Token minted for a different audience | 401 |
| Token from a different issuer | 401 |
| Token signed with a key the service does not trust | 401 |
| An ID token sent where an access token is required | 401 |
| Valid token after "log out everywhere" | 401 within the stated window |
If one of these returns 200, the verification call is configured to check less than the team believes.
Practise the review, not just the exploit
Reading a token and verifying who wrote it are different operations, and the difference is easy to miss in code review. Breachloom has a short chapter on it, Reading a token is not verifying who wrote it, a hands-on exercise, Decoding without verification, and a fix-the-code mission, Verify First, where the repaired function has to pass a regression test, not just look right. The chapters and the free IDOR case need no account; the full set of challenges is part of a paid plan.
Which of the seven checks does your own service verify with a test, rather than by reading the configuration? That is usually the one worth writing down first.
Sources
- RFC 7519, JSON Web Token (JWT): sections 4.1.1, 4.1.3, 4.1.4, 4.1.5, 4.1.7 and 7.2. https://www.rfc-editor.org/rfc/rfc7519
- RFC 8725 (BCP 225), JSON Web Token Best Current Practices, February 2020: sections 2.8, 3.1, 3.3, 3.8, 3.9, 3.10, 3.11, 3.12. https://www.rfc-editor.org/rfc/rfc8725
- RFC 9068, JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens, October 2021: section 2.1 (
typofat+jwt). https://www.rfc-editor.org/rfc/rfc9068 -
josefor Node.js,JWTVerifyOptionsreference. https://github.com/panva/jose/blob/main/docs/jwt/verify/interfaces/JWTVerifyOptions.md
Top comments (0)