Back to Blog
Security 2026-04-29

JWT Security Best Practices for 2026

Avoid the most common JWT vulnerabilities: alg confusion, weak secrets, and replay.

JWTs carry authentication state across millions of systems, and the same handful of implementation mistakes keeps producing full account-takeover vulnerabilities. RFC 8725, the JWT Best Current Practices document, exists precisely because the base spec (RFC 7519) is too permissive to be used naively. Here is what actually goes wrong and how to prevent each failure.

Algorithm Confusion Is Still Vulnerability Number One

The token header declares its own algorithm — and a verifier that trusts that declaration hands the attacker the keys. Two classic attacks:

  • alg none. The spec allows unsigned tokens with "alg": "none". Libraries that accept them treat any forged payload as verified. This is over a decade old and still resurfaces in new wrappers around old libraries.
  • RS256-to-HS256 downgrade. A service that verifies RS256 tokens usually has the RSA public key lying around. If an attacker crafts a token with "alg": "HS256" and signs it using that public key as the HMAC secret, a library that dispatches on the header's alg will verify it successfully — the public key is, by definition, public.

The fix is one line, and it is not optional:

jwt.verify(token, key, { algorithms: ['RS256'] });

Pin the exact algorithm list server-side. Never let the token choose.

Choosing an Algorithm in 2026

  • HS256 — symmetric HMAC. Fine when issuer and verifier are the same service. The secret must be a random 256-bit value: openssl rand -base64 32. A human passphrase reduces token forgery to an offline dictionary attack against a single HMAC.
  • RS256 — RSA signatures. Ubiquitous, verifiers only need the public key, but signatures are large and RSA key generation is slow.
  • ES256 / EdDSA — elliptic curve. Smaller tokens, faster verification; EdDSA (Ed25519) also eliminates the nonce-reuse footgun that has broken ECDSA deployments. Prefer these for new distributed systems.

Distribute public keys through a JWKS endpoint and rotate by publishing the new key before signing with it. Two header fields deserve suspicion: jku and x5u let a token point at an arbitrary URL for its verification key. RFC 8725 is blunt about this — ignore them unless the URL is on a strict allowlist. Similarly, treat kid as an opaque lookup identifier; implementations that interpolate it into file paths or SQL queries have been exploited both ways.

Validate Every Claim, Not Just the Signature

A cryptographically valid token can still be the wrong token:

const payload = jwt.verify(token, key, {

algorithms: ['ES256'],

issuer: 'https://auth.example.com',

audience: 'https://api.example.com',

clockTolerance: 5

});

  • iss — skipping this lets tokens from a different (possibly attacker-registered) tenant through.
  • aud — skipping this lets a token minted for one API replay against another. This is the classic cross-service confused-deputy bug in microservice fleets.
  • exp / nbf — enforce with a small clock tolerance (seconds, not minutes).
  • typ — RFC 9068 defines at+jwt for access tokens; checking it prevents an ID token or refresh token from being accepted where an access token is expected.

Lifetimes, Storage, and Revocation Form One Design

These three decisions only make sense together:

  • Access tokens: 5 to 15 minutes, held in memory only. Never localStorage — any XSS reads it synchronously, and no httpOnly flag can help you there.
  • Refresh tokens: httpOnly, Secure, SameSite=Strict cookies, with rotation: every refresh issues a new token and invalidates the old one. If a rotated-out token is presented again, revoke the whole session — that reuse is the signature of theft.
  • Revocation: a stateless JWT cannot be revoked, so keep lifetimes short enough that the exposure window is acceptable, and maintain a server-side denylist keyed by the jti claim for logout and compromise response. If your product needs instant, reliable revocation everywhere, that is the signal to use opaque session tokens with a session store instead of JWTs — a perfectly respectable architecture.

Payload Hygiene

The payload is base64url-encoded, not encrypted. Anyone holding the token reads every claim with one decoder call. Keep PII, roles you consider secret, and internal identifiers out of it, and keep the token small — it rides on every request, and 4 KB of claims in a cookie can exceed header limits at proxies and CDNs.

A Debugging Checklist

  • Signature invalid after a deploy: key rotation happened without JWKS overlap.
  • Token valid locally, rejected in production: clock skew or a proxy stripping the Authorization header.
  • Intermittent 401s at the top of the hour: exp validated without clockTolerance against a drifting VM clock.
  • Decoding works but verify fails: you are calling decode where you mean verify — decode performs no cryptographic checks at all.

JWS, JWE, and What JWT Actually Names

Strictly speaking, JWT names the claims format; the container is either JWS (signed, readable) or JWE (encrypted). Nearly every "JWT" in the wild is a JWS, which is exactly why nothing in the payload is confidential. If a claim must stay hidden from the client — internal user flags, risk scores — either keep it server-side behind the token or use JWE and accept the extra key-management complexity. Encrypting to hide data you could simply not send is usually the wrong trade.

Inspect headers and claims safely with the JWT Decoder at sdk.is/jwt-decoder — decoding is inspection, never authentication.