Skip to content
d.devtul.fun
中文
Security · 2026-09-18

JWT Security Best Practices: A Checklist for Production

JWTs are easy to issue and easy to get wrong. The attacks against them are well documented, and almost all of them come from a handful of avoidable mistakes: trusting attacker-controlled fields, reusing keys across algorithms, and forgetting that a stateless token cannot be revoked. Treat the list below as a pre-deploy gate. If you cannot check every box, your tokens are not production-ready, and the attack scenarios show exactly how each failure gets exploited.

Pin the algorithm, never trust the token

The single most important rule: the verification side must decide which algorithm is allowed, and the token's own alg header must never be authoritative.

// Wrong: the library reads alg from the token
jwt.verify(token, publicKey);

// Right: force the algorithm explicitly
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

If you let the token pick, an attacker controls the crypto. Two classic exploits follow directly from that mistake, and they are the ones that show up in real breach reports.

Attack: the alg:none forgery

Some libraries, when they see "alg":"none", skip signature verification entirely and treat the token as valid. An attacker takes a captured token, rewrites the header, and drops the signature:

// Forged header
{"alg":"none","typ":"JWT"}
// Forged payload
{"sub":"admin","role":"admin","exp":9999999999}
// Token sent to the server:
// eyJhbGc... . eyJzdWI... .   (empty signature segment)

Because the third segment is empty and the server honored alg:none, it accepts the token as the admin. Defense: reject none at the library level (most modern libraries do), and pin a whitelist so none is never an allowed value. A verifier that can be told "no signature needed" is broken by design, so fail closed: if the algorithm is not on your list, reject before touching a key.

Attack: RS256 to HS256 confusion

Your server verifies tokens with an RSA public key using RS256. An attacker knows that public key - it is, by definition, public. They craft a token that says "alg":"HS256" and sign it with the public key used as the HMAC secret. Because HS256 uses the same key for signing and verifying, and the attacker has the public key, they can produce a signature your server accepts:

// Attacker builds an HMAC token where the "secret" is your public key
const forged = jwt.sign({ sub: 'admin' }, publicKey, { algorithm: 'HS256' });
// Your server verifies with the same publicKey and algorithms:['RS256']?
// If you forgot to pin RS256, it tries HS256 and the forgery passes.

Defense: pin the algorithm to RS256 so an HS256 token is rejected before any key is touched, and bind each key to the algorithm it supports. An RSA public key should never be usable as an HMAC secret. Libraries that let you pass a single key for both algorithms are the root cause; give the verifier a key object that declares its type, so the verifier refuses to use an RSA key for an HMAC operation.

Attack: kid path traversal and SQL injection

The kid (key id) header tells the verifier which key to load. If the server builds a file path or a database query from kid without validation, an attacker steers it:

// Vulnerable: kid is concatenated into a path
const key = fs.readFileSync('/keys/' + header.kid + '.pem');
// Attacker sends header: { "kid": "../../../../etc/passwd", "alg":"HS256" }
// Now the server reads /etc/passwd as the "key" and verifies an HMAC token against it.

A variation injects SQL into a SELECT key FROM keys WHERE id='<kid>' lookup. Defense: keep an allowlist of valid key ids, or map kid to a key through a fixed dictionary, never through string concatenation into a path or query. If you must store keys in a database, use a parameterized query and reject any kid containing /, \, or quote characters. The moment a header value reaches the filesystem or the SQL string, you have already lost.

Key management discipline

Beyond the attacks, the boring parts matter. Use RSA keys of at least 2048 bits, preferably 4096, and rotate them on a schedule. Publish the rotation through a JWKS endpoint and keep the old key valid for a short overlap so in-flight tokens still verify. Never embed an HS256 secret in client code: a secret shipped in a mobile app or browser bundle is no longer secret, and anyone can forge tokens. Generate secrets with a CSPRNG, not from a passphrase or a constant. Audit where keys live - a key in a committed .env file or pasted into a chat is the most common real-world JWT breach and has nothing to do with algorithm confusion. Treat the signing key like a production database password: in a vault, rotated, and never in source control.

Keep the payload boring

The payload is Base64url, readable by anyone who holds the token. Do not put emails, addresses, or anything sensitive there, and avoid large arrays of permission names that leak your org structure to anyone who decodes a token. Keep it to identifiers and flags. Keep expiry short: access tokens should live minutes, not hours. A stolen short-lived token has a small blast radius, while a token valid for a day is a day-long open door. If you need longer sessions, use a refresh-token flow rather than a long access token.

Store refresh tokens away from JavaScript

Refresh tokens are long-lived, so their storage is the difference between a minor leak and a full account takeover. Put them in httpOnly, Secure, SameSite cookies, not in localStorage:

StorageXSS exposureCSRF exposure
localStorageHigh - any script can read itNone directly
httpOnly cookieNone - JavaScript cannot read itPossible - close with SameSite plus CSRF tokens

A single XSS flaw hands a localStorage token straight to the attacker; an httpOnly cookie is invisible to scripts. You pay with a CSRF door, which you close using SameSite=Strict and double-submit CSRF tokens. That trade is almost always worth it, because token theft via XSS is far more common than successful CSRF against a SameSite cookie.

How to actually revoke a JWT

Stateless JWTs cannot be invalidated before exp, which breaks "log out now" and "this token was stolen." Three patterns fix it, each with a cost.

Denylist of revoked jti

Give every token a unique jti claim, and on logout or breach, add that jti to a fast store (Redis). Every request checks the list. Cost: you now keep state, you need a store, and the list grows until tokens expire, so you must TTL entries to exp to stop it from growing without bound.

Short access tokens plus refresh rotation

Issue access tokens that live one to five minutes and a refresh token you rotate on every use. To revoke, invalidate the refresh token; the next access token dies with it. Cost: more moving parts and a refresh-endpoint to protect, but you avoid a global denylist and get near-instant revocation. This is the most common production pattern for exactly that reason.

Version or fingerprint claim

Embed a token_version or a device fingerprint in the payload and store the current version per user. Changing the version on password reset or logout invalidates every old token at once. Cost: a lookup per request, and you must keep that version store consistent across services. It shines when you want a single "kill all sessions" button.

Always validate the standard claims

Check iss so the token came from the authority you trust, and aud so a token minted for service A cannot be replayed against service B - and make that an exact match, not a substring check. Verify exp, nbf, and iat with a few seconds of clock skew so servers with slightly different clocks do not bounce valid tokens, and reject tokens issued before a known good time after a key compromise. Skipping these checks is how token-replay bugs slip across microservices.

The one-line summary

Pin the algorithm, keep secrets server-side, keep payloads boring, keep access tokens short, park refresh tokens in httpOnly cookies, and always check iss/aud/exp. When you need to revoke, pick a denylist, refresh rotation, or a version claim based on how fast you must react. Do those and you avoid the 90% of JWT incidents that are just the same old mistakes repeated.

Keep reading