Most APIs that use JWTs check whether a token is present and whether it can be decoded. They stop there. The problem is that decoding a JWT requires no secret and no verification. Any string that looks like three base64 segments separated by periods will decode successfully. The application extracts the user ID from the payload, loads that user's data, and grants access. An attacker who changes the sub claim from 1234 to 5678 becomes user 5678.
This happens because developers treat JWTs as opaque tokens when they are actually signed or encrypted data structures. The signature exists to prevent tampering, but only if the application actually verifies it. I see APIs that decode the payload and immediately trust every claim inside without ever checking the signature against a secret or public key.
Why This Hides
JWT libraries make verification optional. Most provide separate methods for decoding and verifying, and the decode method works perfectly during development. The token parses, the claims look correct, and everything functions. The missing verification step causes no errors and no visible breakage. Code review often misses it because the JWT handling is tucked into middleware or a utility function, and reviewers assume the library is doing the secure thing by default. It is not.
My Testing Method
- Capture a valid JWT from an authenticated session. Decode it using a tool or library to see the header and payload structure. Note the algorithm in the
algheader and identify which claims control identity, such assub,user_id, oremail. - Modify a claim that determines access or identity. Change the user ID to another valid value, or escalate a role from
usertoadmin. Re-encode the header and payload without signing, or sign it with a key you control. - Send the modified token in a request to a protected endpoint. If the API accepts it and returns data for the altered user or role, signature verification is missing or broken.
- Test the
algfield by changing it tonone. Remove the signature portion entirely, leaving the trailing period. Some libraries honor this and skip verification whenalgis set tonone. - If the API uses asymmetric signing with RS256, try changing the algorithm to HS256 and signing the token with the public key. If the server uses the same key material to verify HMAC signatures that it uses for RSA public key verification, this can succeed.
- Check for missing expiration enforcement by replaying an old token or by removing the
expclaim entirely. Verify that the API rejects expired tokens and enforces reasonable TTLs. - Test issuer and audience validation by changing the
issoraudclaims to arbitrary values. These claims tie a token to a specific system and should be validated, but often are not.
The Deeper Issue
Defensive Implementation
When implementing JWT validation, always use the verify method provided by your library, never the decode method alone. Explicitly specify the allowed algorithms and reject none. Validate iss, aud, and exp claims as part of every verification call. Store signing secrets securely and rotate them periodically.
const jwt = require('jsonwebtoken');
function verifyToken(token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'api.example.com',
audience: 'web-client'
});
return decoded;
} catch (err) {
throw new Error('Invalid token');
}
}
Why It Persists
JWT validation flaws survive because the failure mode is silent. An application that skips verification works exactly like one that performs it, until an attacker notices. Developers see tokens flowing through the system and assume the library is handling security. Documentation often shows decoding examples first and buries verification in later sections. The gap does not surface in functional testing, only in adversarial testing.
I test every API that uses JWTs by modifying tokens and observing whether the changes are rejected. Verification must happen before trust.