Verify a JWT Signature Without Trusting the Header
The classic alg=none and algorithm confusion attacks both come from trusting what the token tells you. Here is how to verify JWTs the right way.
JSON Web Tokens are signed, which sounds secure until you realize that by default many libraries let the token itself tell the verifier which algorithm to check. That is the design flaw behind the two most famous JWT vulnerabilities: the alg=none bypass, and RS256-to-HS256 algorithm confusion. This tutorial shows how both work and what correct verification looks like.
Quick JWT refresher
A JWT has three base64url-encoded parts, separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWxpY2UifQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
β header β payload β signature
The header declares the algorithm:
{ "alg": "HS256", "typ": "JWT" }
That field is the problem. Many libraries read alg from the header and pick their verifier based on it. An attacker who controls the token controls the header.
Attack 1 β alg: none
The JWT spec includes alg: "none", meaning "this token is unsigned." A naΓ―ve library reads the header, sees none, and skips verification entirely. The signature part of the token can be empty. Any payload the attacker writes is accepted.
Real-world example of a forged token:
eyJhbGciOiJub25lIn0.eyJ1c2VyIjoiYWRtaW4ifQ.
Decoded: alg: none and user: admin. Empty signature. If the server honors alg: none, the attacker is now admin.
Mitigation: never let alg come from the token. Explicitly pass the expected algorithm into your verifier.
Attack 2 β RS256 β HS256 algorithm confusion
RS256 verifies with a public key. HS256 uses the same secret for signing and verification (it is symmetric).
If your server verifies tokens with a publicKey variable and the library picks its algorithm from the token header, an attacker can:
- Take the server's public key (often literally public β
.well-known/jwks.jsonor a PEM in docs) - Craft a token with
alg: HS256 - HMAC-sign the token using the public key as the HMAC secret
- Send it to the server
The server reads alg: HS256, grabs its publicKey variable as the "secret", and successfully verifies the token β because the attacker signed it with that exact value.
Mitigation: same as above. Pin the algorithm. Do not let the header choose.
Step 1 β The right way in Node.js (jsonwebtoken)
import jwt from 'jsonwebtoken';
import fs from 'node:fs';
const publicKey = fs.readFileSync('./public.pem');
function verifyToken(token) {
try {
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // β pin the algorithm
issuer: 'https://auth.example.com',
audience: 'api.example.com',
clockTolerance: 30, // seconds; accounts for clock skew
});
return payload;
} catch (err) {
// verification failed β treat as unauthenticated
return null;
}
}
The critical line is algorithms: ['RS256']. That argument is an allowlist β the library will reject any token whose header declares anything else, including none. Without this option jsonwebtoken will happily let the header decide, which is exactly the vulnerability.
Step 2 β The right way in Python (PyJWT)
import jwt
with open('public.pem', 'rb') as f:
public_key = f.read()
def verify_token(token: str) -> dict | None:
try:
return jwt.decode(
token,
public_key,
algorithms=['RS256'], # pin algorithm
issuer='https://auth.example.com',
audience='api.example.com',
leeway=30, # clock skew, seconds
)
except jwt.InvalidTokenError:
return None
PyJWT requires the algorithms argument β it throws if you omit it. That is good API design and has probably prevented a lot of production incidents.
Step 3 β What to verify beyond the signature
A valid signature means the token has not been tampered with since the issuer signed it. It does not mean the token is meant for you, still valid, or from whom you think it is. Check these claims every time:
iss(issuer) β must match your trusted auth serveraud(audience) β must match your serviceexp(expiration) β must be in the futurenbf(not before) β must be in the past (if present)iat(issued at) β sanity-check it is not suspiciously old or in the future
All the libraries above will check exp/nbf/iat automatically if the claims are present. iss and aud must be checked explicitly, as shown.
Step 4 β For JWKS-based setups, pin the key ID
If your identity provider rotates keys and publishes them via JWKS (.well-known/jwks.json), the token header carries a kid (key ID) so the verifier knows which key to use. That lookup is fine, but:
- Fetch the JWKS only from a hardcoded URL β never one derived from the token
- Cache the JWKS; do not re-fetch per request, or your auth server becomes a DDoS target
- Reject tokens whose
kidis not in the JWKS, rather than refreshing the JWKS on every unknownkid(an attacker could flood you with randomkidvalues to force cache misses)
A small Node example:
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json'),
{ cooldownDuration: 60_000 } // cap re-fetch rate
);
export async function verify(token) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'api.example.com',
algorithms: ['RS256'],
});
return payload;
}
The jose library in particular has a solid track record of refusing to do unsafe things by default, which is why I reach for it in new projects.
Checklist for any JWT verifier in your codebase
Go through your code and confirm:
- The algorithm is pinned to an allowlist at the verify call site
-
issis verified against a hardcoded value -
audis verified against a hardcoded value -
expis checked (usually automatic, but worth confirming) - The key material source is hardcoded, not derived from the token
- No code path calls
decodewhen it should be callingverify - Any inspection of the token payload before verification is clearly marked as untrusted
That last one trips people up. jwt.decode (without verification) is legitimate for looking up which key to fetch, but the payload from decode is attacker-controlled until verify returns cleanly. Treat it accordingly.
Inspecting suspicious tokens
If you are reviewing a log and want to decode a JWT to see what was inside β without actually trusting it β paste it into SysEmperor's JWT Decoder. The tool runs entirely in the browser, so the token never leaves your device, which matters when the token in question may contain session info you would rather not post to a random online decoder.
SysEmperor