JWT verification pitfalls: algorithms, claims and keys
A signature only proves integrity, not that a token is for you. The JWT header is attacker-controlled, so a verifier that trusts its alg/kid invites alg:none and HS256/RS256 key confusion. Pin an algorithms allowlist, validate aud/iss/exp, and key JWKS by kid.
The pentest report had one line that ruined the sprint: “authentication bypass, admin access, no credentials required.” The app issued RS256 tokens from its IdP and verified them with the IdP’s public key — textbook. But the JwtStrategy was configured with the secret-or-key set and nothing else: no algorithms, no audience, no issuer. The tester never stole a key. They took a normal token, flipped the header’s alg to a symmetric one, and re-signed it using the server’s public key as the HMAC secret — a key printed in the IdP’s discovery document for the world to read. The verifier, told nothing about which algorithm to expect, happily took the symmetric path and validated the forgery. A valid signature was checked. It just wasn’t the signature anyone with secrets had to produce. This lesson is about the gap between “the signature verified” and “I can trust this token.”
The header is untrusted input
A JWT is three parts: header, payload, signature. The instinct is to treat the whole token as a sealed envelope you only have to check the seal on. But the header — including alg (which algorithm signed this) and kid (which key) — arrives before you’ve verified anything. It is attacker-controlled input. A verifier that reads alg from the header and then verifies “however the token says to” has handed the attacker the steering wheel: they pick the algorithm, you obey. Two classic bypasses live in exactly this gap.
alg: none. The JWS spec defines an unsecured algorithm literally named none, meant for cases where integrity is guaranteed by other means. A token with alg: none carries an empty signature. A naive verifier that honors the header’s choice will see “algorithm: none, signature: (empty)” and conclude the token is validly signed — because for none, an empty signature is valid. The attacker rewrites the payload to role: admin, sets alg: none, sends no signature, and is now whoever they want to be.
HS256/RS256 key confusion. This is the one from the Hook, and it’s subtler. RS256 is asymmetric: the IdP signs with a private key, you verify with the matching public key. HS256 is symmetric: the same secret both signs and verifies. Now suppose your verify code is handed the RS256 public key but selects the algorithm from the header. The attacker sends alg: HS256. A library that trusts the header takes the HMAC path — and uses the bytes you gave it (the RS256 public key) as the HMAC secret. The public key is public. The attacker has it. So they compute a valid HS256 MAC over their forged payload and the verifier confirms it.
// THE VULNERABILITY: the verifier lets the token choose the algorithm.
// No `algorithms` allowlist, no audience, no issuer.
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: PUBLIC_KEY, // RS256 public key — but nothing pins RS256
// BUG: omitting `algorithms` means an alg:none or HS256 token is accepted
// BUG: omitting `audience`/`issuer` means any signed token is trusted
});
}
validate(payload: any) { return { userId: payload.sub, role: payload.role }; }
}The fix for both is the same one move: stop letting the token pick. Pass an explicit allowlist of acceptable algorithms to the verify call. If you expect RS256, say algorithms: ['RS256'], and the verifier will refuse none and refuse HS256 before it ever looks at the signature — the symmetric path and the unsecured path simply don’t exist for this verifier.
// THE FIX: pin the algorithm, the audience, and the issuer.
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
// for an external IdP, fetch keys from its JWKS endpoint, selected by `kid`:
secretOrKeyProvider: passportJwtSecret({
cache: true,
rateLimit: true,
jwksUri: config.get('IDP_JWKS_URI'),
}),
algorithms: ['RS256'], // <- the token can no longer choose
audience: config.get('API_AUDIENCE'), // <- this token was minted for US
issuer: config.get('IDP_ISSUER'), // <- by OUR trusted IdP
// exp/nbf are validated by default unless you disable them — leave them on
});
}
validate(payload: JwtPayload) { return { userId: payload.sub, role: payload.role }; }
}▸Why this works
Why does verifying with an RS256 public key while accepting an HS256 token let an attacker forge tokens? Because the verifier reuses whatever key bytes it was given as the input to whichever algorithm the header named — and for HS256 those bytes become the HMAC secret. The entire security of HMAC rests on the secret being secret. But an RS256 public key is, by definition, published — it’s in the IdP’s JWKS document, served to anyone. So “use the public key as an HMAC secret” means “use a value everyone already knows as the thing that’s supposed to be unknown.” Anyone can then compute a matching MAC over any payload. Pinning algorithms: ['RS256'] removes the HMAC path entirely: the verifier never interprets those key bytes as a symmetric secret, so the confusion can’t happen.
A valid signature is not enough
Suppose you’ve pinned the algorithm and the signature genuinely verifies. You still cannot trust the token’s claims, because a signature proves exactly one thing: the bytes weren’t tampered with by someone without the key. It says nothing about when the token is valid or who it was for.
exp and nbf — freshness. exp (expiry) and nbf (not-before) bound the token’s validity window. Most libraries check these automatically unless you turn them off (passport-jwt’s ignoreExpiration: true is a foot-gun). A signature stays valid forever; only exp makes a leaked or logged-out token stop working. Verify them.
aud — audience. aud names the service the token was minted for. This is the claim teams forget, and it causes the confused-deputy failure. Imagine an internal service and a public API that, for convenience, share one signing key (or one IdP that mints for both). A token issued for the internal service is a perfectly valid, correctly signed token. Without an aud check, the public API accepts it — the bearer now wields a token never intended for that surface. Validating audience says “I only accept tokens whose aud is me,” and the cross-service replay dies.
iss — issuer. iss pins which IdP you trust. If you skip it and any of your trusted-key set overlaps with another issuer, a token from the wrong authority can slip through. Validate that iss is your IdP.
And don’t over-trust application claims like roles or sub beyond what the signature covers and what your authorization layer re-checks — the signature guarantees the issuer put those claims there, not that they still reflect the user’s current state.
Together, these four checks — exp/nbf, aud, and iss — close the gap between “a signature verified” and “I can safely trust this token.” Without any one of them, a correctly signed token from the wrong time, the wrong service, or the wrong authority slips through.
Keys that can rotate
The last pitfall is key management. For asymmetric verification against an external IdP (Auth0, Cognito, Keycloak), do not hardcode a public key. IdPs rotate signing keys, and a hardcoded key turns a routine rotation into an outage — or worse, pressures you to disable verification to “fix” it. Instead, fetch public keys from the IdP’s JWKS endpoint and select the right one by the token’s kid header, with caching and a refresh path so new keys are picked up. In NestJS this is the secretOrKeyProvider wired to jwks-rsa’s passportJwtSecret, with cache: true and rateLimit: true so you neither hammer the endpoint nor cache a retired key forever.
For the symmetric case (HS256 with a shared secret), the secret carries the entire trust model: anyone holding it can forge any token. So it must be high-entropy (not a dictionary word, not the literal string "secret") and per-environment, so a leaked staging secret can’t forge production tokens. A weak or leaked HS256 secret is total compromise — there is no public/private split to fall back on.
You must harden a NestJS JwtStrategy that verifies access tokens minted by an external IdP (Auth0/Cognito/Keycloak). What must the verification be configured to do?
A token arrives with header alg:none and an empty signature, its payload edited to role:admin. A misconfigured server accepts it as valid. Why — and what is the one-line fix?
You pinned algorithms: ['RS256'] and the signature verifies correctly. Why is that still not enough to trust the token?
- 01Explain the two header-driven JWT bypasses (alg:none and HS256/RS256 key confusion) and the single configuration move that closes both.
- 02Why is a valid signature not enough to trust a token, and what must a hardened NestJS verifier additionally check — including key management for an external IdP?
A JWT’s header — its alg and kid fields — is attacker-controlled input that arrives before verification, so a verifier that trusts it hands the attacker the steering wheel. Two header-driven bypasses follow: alg:none, where the spec’s unsecured algorithm makes an empty signature ‘valid’ so an attacker rewrites the payload and sends no signature; and HS256/RS256 key confusion, where a verifier holding the RS256 public key but letting the header choose the algorithm takes the HMAC path and uses that published public key as the HMAC secret, letting anyone forge a valid MAC. The single fix for both is to pin an explicit algorithms allowlist (algorithms: [‘RS256’]) so the verifier dictates the algorithm and rejects ‘none’ and any symmetric algorithm before reading the signature. But a valid signature only proves integrity, not freshness or audience, so a hardened verifier must also validate exp/nbf (kept on by default — never ignoreExpiration), aud (this token was minted for THIS API, closing the confused-deputy cross-service replay under a shared key), and iss (from your trusted IdP). Finally, manage keys for rotation: for an external IdP resolve the public key from its JWKS endpoint by kid with caching, rather than a hardcoded key that turns rotation into an outage; for HS256 keep the shared secret high-entropy and per-environment. This is RFC 8725, the JWT BCP, and in NestJS each defense is one explicit option on the JwtStrategy: algorithms, audience, issuer, and a secretOrKeyProvider backed by jwks-rsa. Now when you review a JwtStrategy and see no algorithms option, no audience, no issuer — you know you’re looking at a verification path the Hook describes: one that checks a signature while ignoring who signed it, for whom, and when.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.