Skip to content
Developer 11 min · May 25, 2025

JWT Security Best Practices Every Backend Developer Should Follow

Common JWT pitfalls per RFC 7519 including algorithm confusion, expiry handling, and key rotation.

H
HT99 Tools Editorial Team
Editorial Team

What a JWT Is, per RFC 7519

A JSON Web Token, specified in RFC 7519 (Jones, Bradley, Sakimura, May 2015), is a compact, URL-safe way to represent claims between two parties. A JWT has three parts — header, payload, signature — each base64url-encoded and joined with periods to form header.payload.signature. The header declares the token type (JWT) and the signing algorithm (HS256, RS256, ES256, and so on). The payload carries the claims: standard registered claims like iss (issuer), sub (subject), aud (audience), exp (expiration), nbf (not before), iat (issued at), and jti (unique JWT ID), plus any private claims your application needs. The signature is computed over the base64url-encoded header and payload using the algorithm and key declared in the header.

The single most important fact about JWTs — the fact that drives every security consideration in this article — is that JWTs are signed, not encrypted. The base64url encoding is reversible by anyone; the payload is visible to anyone who holds the token. If you put a user's email, role, or social security number in the payload, anyone who intercepts the token can read it. JWTs guarantee integrity (the token was not tampered with) and authenticity (the issuer held the signing key), but they do not guarantee confidentiality. If you need confidentiality, use JWE (JSON Web Encryption, RFC 7516) on top of, or instead of, JWT.

The alg:none Attack

The most famous JWT vulnerability is the alg:none attack. RFC 7515 (JSON Web Signature) defines none as a valid algorithm identifier for use in test environments where no signature is required. A malicious client can take a valid token, change the header's alg to none, drop the signature entirely, and submit the modified header.payload. (with the trailing period but no signature). Libraries that trust the header's alg field will skip signature verification and accept the token as valid.

The fix is mandatory: never trust the algorithm in the token header. Maintain a server-side allowlist of acceptable algorithms (typically exactly one, the one you sign with) and reject any token whose header specifies a different algorithm. Modern JWT libraries make this easy — for example, Node's jsonwebtoken library takes an algorithms array in its verify call — but the bug still appears in hand-rolled verification code and in tutorials that predate the fix. Treat any JWT verification code that does not explicitly pass an algorithm allowlist as suspicious.

The Algorithm Confusion Attack

A subtler variant: suppose your server signs tokens with RS256 (asymmetric, RSA private key signs, RSA public key verifies). The public key is, by definition, public. An attacker who knows the public key can craft a token whose header says alg: HS256 (symmetric, HMAC with shared secret) and whose signature is HMAC-SHA256 of the header and payload using the public key as the HMAC secret. If the verifying code uses the key material from the JWT header rather than from a server-side configuration, it will treat the public key as an HMAC secret, compute HMAC-SHA256 with it, and accept the forged signature.

The fix is the same as for alg:none: bind the algorithm to the key server-side. The key used to verify a token must be selected based on the algorithm you expect, not the algorithm the token claims. If the expected algorithm is RS256, fetch the RSA public key by key ID (from the kid header, validated against a server-side trust store) and verify with RS256 — never with HS256.

Expiry and Clock Skew

The exp claim is a Unix timestamp indicating when the token expires. RFC 7519 requires verifiers to reject expired tokens. The nbf claim indicates when the token becomes valid; iat indicates when it was issued. All three are Unix timestamps in seconds.

Clock skew between issuer and verifier is a real concern. If the issuer's clock is 30 seconds ahead of the verifier's, a freshly minted token with nbf set to "now" will appear to be from the future to the verifier and will be rejected. Most JWT libraries accept a clockTolerance parameter to allow a small leeway — 30 seconds is typical — but setting it too high weakens the exp guarantee. Keep clock skew under control at the infrastructure level using NTP, and use a modest clock tolerance only as a safety net.

Token lifetime matters as much as expiry checking. Long-lived access tokens (days or weeks) increase the window during which a stolen token can be replayed. Access tokens should be short-lived — 5 to 15 minutes is a common range — and paired with a longer-lived refresh token that can be revoked server-side. When the access token expires, the client uses the refresh token to obtain a new one. This pattern bounds the damage of a stolen access token to the token's lifetime.

Revocation: The Stateless Paradox

JWT's main appeal is statelessness: the server does not need a database lookup to verify a token, because the signature carries all the verification data. JWT's main weakness is also statelessness: the server cannot revoke a token before its exp without a server-side blacklist, which reintroduces the database lookup the design was trying to avoid.

Three patterns handle this trade-off. The first is to keep access tokens short-lived (minutes) and accept that a stolen token is valid until it expires; revocation only matters for refresh tokens, which live in a database anyway. The second is to maintain a small server-side blacklist of revoked tokens keyed by jti, with entries that expire when the corresponding token would have expired; this bounds the blacklist size. The third is to rotate signing keys: when a key is compromised, you publish a new key and stop trusting the old one, invalidating all tokens signed with the old key. This is a coarse instrument but it is the only option that requires no server-side state at all.

Key Rotation

Key rotation means periodically generating a new signing key, serving both old and new keys during a transition window, and then retiring the old key. RFC 7517 (JSON Web Key) defines a standard format for publishing public keys as a JWKS (JSON Web Key Set) at a well-known URL; this is how Auth0, Cognito, Firebase Auth, and most OIDC providers distribute their keys. Tokens carry a kid (key ID) header that tells the verifier which key in the JWKS to use.

Rotation intervals of weeks to months are typical; emergency rotation (after a suspected compromise) should be possible within minutes, which means the JWKS endpoint must be cacheable but with a short TTL — 5 to 15 minutes is common. Verifiers must re-fetch the JWKS when they encounter a kid they have not seen, not just on a fixed schedule, to support emergency rotation.

Storing Tokens in the Browser

Where to store a JWT in the browser is a perennial debate. The two main options are localStorage and HTTP-only cookies. localStorage is accessible to any JavaScript running on the page, which means a single cross-site scripting (XSS) vulnerability can exfiltrate the token. HTTP-only cookies are not readable by JavaScript, which closes that vector, but they are vulnerable to cross-site request forgery (CSRF) unless paired with CSRF tokens or the SameSite cookie attribute.

The current best practice for browser-based SPAs is to store refresh tokens in HTTP-only, Secure, SameSite=Strict cookies, and to keep access tokens in memory only — never in localStorage or sessionStorage. The access token lives only as long as a page reload; the refresh token (in the HTTP-only cookie) is automatically sent to the token endpoint to mint a new access token on each page load. This pattern limits the XSS attack surface to in-memory tokens that die on reload, while the refresh token stays safe behind the cookie's protections.

Claims: Less Is More

Because JWT payloads are not encrypted, every claim you add is data leaked to anyone who holds the token. Include only what the resource server needs to authorize the request: typically sub (user ID), iss, aud, exp, and possibly a scope or role claim. Do not include the user's email, phone number, or physical address unless the resource server actually needs that data on every request — and if it does, consider whether the data would be better fetched from a profile endpoint behind the access token rather than embedded in the token itself. Keep tokens small; they travel in HTTP headers that some proxies cap at 8 KB.

Conclusion

JWTs are signed, not encrypted; that one fact drives almost every JWT security decision. Verify with an algorithm allowlist bound server-side to defeat alg:none and algorithm confusion attacks. Keep access tokens short-lived and use refresh tokens for sessions that last longer. Accept that stateless JWTs cannot be cleanly revoked; choose short lifetimes, a small jti blacklist, or key rotation as your revocation strategy. Rotate signing keys regularly and on compromise, publishing them as JWKS at a well-known URL. Store refresh tokens in HTTP-only cookies and access tokens in memory only, never in localStorage. Put the minimum claims necessary in the payload, because anyone holding the token can read it. JWT is a useful tool; treating it as a session-replacement without understanding its limitations is how production incidents happen. Written by the HT99 Tools Editorial Team.

Try the Tool This Article Explains

Put what you've learned into practice with our free, accurate calculators.

Browse All Tools → More Articles