JWT signature verification looks simple — the library does the crypto. The security holes are in how you configure it: which algorithm you accept, whether you check expiration, whether you validate issuer and audience.
Prerequisites
- The JWT token
- The verification key (secret for HS*, public key for RS*/ES*)
- Expected values for
iss,aud, and other claims
HS256 (Shared Secret) — Node.js
const jwt = require('jsonwebtoken');
try {
const decoded = jwt.verify(token, SHARED_SECRET, {
algorithms: ['HS256'], // Allowlist — critical
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
clockTolerance: 30, // seconds for clock skew
});
// decoded contains verified claims
} catch (err) {
// TokenExpiredError, JsonWebTokenError, NotBeforeError
console.error('Verification failed:', err.message);
}
RS256 (Public Key) — Node.js
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
cache: true,
rateLimit: true,
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
callback(null, key.getPublicKey());
});
}
jwt.verify(token, getKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
}, (err, decoded) => {
if (err) return console.error(err);
// decoded is verified
});
Python (PyJWT + jwks-fetching)
import jwt
from jwt import PyJWKClient
jwks_client = PyJWKClient('https://auth.example.com/.well-known/jwks.json')
try:
signing_key = jwks_client.get_signing_key_from_jwt(token)
decoded = jwt.decode(
token,
signing_key.key,
algorithms=['RS256'],
issuer='https://auth.example.com',
audience='https://api.example.com',
leeway=30, # clock skew tolerance
)
except jwt.InvalidTokenError as e:
print(f'Verification failed: {e}')
The Non-Negotiable Checks
Every verification must include:
algorithms=['RS256'](or whatever you expect) — allowlist explicitly. NEVER trust the alg field from the JWT header alone. Thealg: noneattack and the RS256/HS256 confusion attack both exploit this.- Signature verification — the library does this automatically when given the correct key
exp(expiration) — libraries check by default; do not disablenbf(not before) — libraries check by defaultiss(issuer) — required. Pin to the expected issuer.aud(audience) — required for OAuth. Pin to your service identifier.
Timing Attack Prevention
Library comparison functions (jwt.verify, jwt.decode) use constant-time comparison internally. Never write your own:
// BAD:
if (calculatedSig === token.signature) { ... }
// GOOD:
const crypto = require('crypto');
crypto.timingSafeEqual(Buffer.from(calculatedSig), Buffer.from(token.signature));
Inspect Before Coding
Before writing verification code, decode a sample JWT with the JWT decoder to see:
- Which
algthe issuer uses (matches your allowlist) - What
issandaudclaims look like - What custom claims exist
Common Miss
- Not restricting
algorithms— attackers picknoneor swap RS256 for HS256 (using the RS256 public key as an HMAC secret) - Skipping
iss/audvalidation — cross-service token replay - Not handling clock skew — brief
expfailures during clock drift - Assuming decoded = verified — decoding never verifies; must call
verify()explicitly - Storing symmetric keys in code / logs — treat HS256 secrets like passwords
Read what a JWT is, check the JWT glossary entry, and see the JWK entry for key-discovery details.
Related
Read what a JWT is, check the JWT glossary entry, and see the JWS entry for signature-format details.