env.dev

JWT Best Practices: Storage, Algorithms & Revocation

Security best practices for JSON Web Tokens: algorithm selection, storage, expiration, refresh patterns, revocation, and common vulnerabilities.

Last updated:

JWTs are the most widely used token format for API authentication, but a misconfigured JWT implementation is worse than no authentication at all. An attacker who forges or steals a token gains full access with zero server interaction. This guide covers the security practices every production system should follow — from signature validation and algorithm selection to token storage, refresh patterns, and revocation strategies. If you are new to the format, read What is a JWT? first.

Why should you always validate the signature?

A JWT without signature verification is just a JSON blob anyone can edit. The signature is the only mechanism that proves the token was issued by your server and has not been tampered with. Always verify with the expected algorithm and key — never decode the payload alone.

typescript
import { jwtVerify } from 'jose';

const secret = new TextEncoder().encode(process.env.JWT_SECRET);

async function verifyToken(token: string) {
  // jwtVerify throws if signature is invalid or token is expired
  const { payload } = await jwtVerify(token, secret, {
    algorithms: ['HS256'],  // explicitly restrict allowed algorithms
    issuer: 'https://auth.example.com',
    audience: 'https://api.example.com',
  });
  return payload;
}

Specifying algorithms, issuer, and audience prevents multiple classes of attack in a single call. Treat JWT_SECRET like any other production secret — see environment variable security for handling, rotation, and leak detection.

How short should token expiration be?

The exp claim is your primary line of defense against stolen tokens. Industry recommendation: 5-15 minutes for access tokens. A shorter expiration window shrinks the attack surface — even if a token leaks, it becomes useless quickly.

typescript
import { SignJWT } from 'jose';

const secret = new TextEncoder().encode(process.env.JWT_SECRET);

async function issueAccessToken(userId: string) {
  return new SignJWT({ sub: userId, role: 'user' })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('15m')   // 15-minute access token
    .setIssuer('https://auth.example.com')
    .setAudience('https://api.example.com')
    .sign(secret);
}

Never issue tokens without exp. A token without expiration is valid forever if compromised.

Why use asymmetric algorithms in production?

Symmetric algorithms like HS256 require every service that verifies tokens to hold the same secret. In a microservices architecture this is a significant risk — one compromised service exposes the signing key. Asymmetric algorithms (RS256, ES256) solve this: only the auth service holds the private key, while all other services verify with the public key.

AlgorithmKey typeKey sizeBest for
HS256Symmetric256-bit secretSingle-service, internal tools
RS256RSA key pair2048+ bitMulti-service, JWKS-based verification
ES256ECDSA key pairP-256 curveCompact tokens, mobile apps, high throughput

ES256 produces smaller signatures than RS256 (64 bytes vs 256 bytes) and is faster to sign. Prefer it for new systems unless you need RSA for compatibility.

What should you never store in a JWT payload?

JWT payloads are Base64URL-encoded, not encrypted. Anyone with the token can decode the payload instantly. Never include passwords, credit card numbers, API keys, PII like social security numbers, or any data you would not want exposed publicly. Stick to identifiers and roles.

Safe payload claims: sub (user ID), role, iss, aud, exp, iat, jti (token ID for revocation).

Never include: passwords, secrets, tokens, full email addresses, financial data, or any sensitive PII.

How does the access + refresh token pattern work?

Short-lived access tokens limit damage from theft, but forcing users to re-authenticate every 15 minutes is unacceptable. The solution: pair a short-lived access token with a longer-lived refresh token. The refresh token is stored securely and exchanged for new access tokens without requiring credentials.

typescript
import { SignJWT, jwtVerify } from 'jose';
import crypto from 'node:crypto';

const secret = new TextEncoder().encode(process.env.JWT_SECRET);

async function issueTokenPair(userId: string) {
  const accessToken = await new SignJWT({ sub: userId })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('15m')
    .setIssuedAt()
    .sign(secret);

  // Refresh token: opaque, stored in DB with expiration
  const refreshToken = crypto.randomBytes(64).toString('base64url');
  // Store in database: { token: refreshToken, userId, expiresAt: Date.now() + 7 days, family: familyId }

  return { accessToken, refreshToken };
}

async function refresh(refreshToken: string) {
  // 1. Look up refresh token in database
  // 2. Check it has not expired or been revoked
  // 3. Invalidate the old refresh token (rotation)
  // 4. Issue a new token pair
  return issueTokenPair(/* userId from DB lookup */);
}

Always rotate refresh tokens on use. If a refresh token is used twice, it indicates theft — revoke the entire token family.

Where should you store tokens on the client?

Token storage directly impacts your vulnerability to XSS and CSRF attacks. There is no perfect solution, but some options are significantly safer than others.

HttpOnly cookies (recommended)

JavaScript cannot access HttpOnly cookies, eliminating XSS token theft entirely. Set Secure, SameSite=Strict, and Path=/. Add CSRF protection (double-submit cookie or synchronizer token).

In-memory (good for SPAs)

Store the access token in a JavaScript variable. It survives as long as the tab is open and is invisible to XSS in other tabs. Combine with a refresh token in an HttpOnly cookie to rehydrate on page load.

localStorage (avoid if possible)

Accessible to any JavaScript running on the page. A single XSS vulnerability exposes the token. If you must use it, pair it with very short expiration times and strict Content Security Policy headers.

What are effective revocation strategies?

JWTs are stateless by design — the server does not track them. This makes revocation inherently difficult. Three practical strategies:

  • Short-lived tokens. The simplest approach. With 5-15 minute access tokens and refresh token rotation, compromised tokens expire quickly without any server-side state.
  • Deny list. Maintain a server-side set of revoked jti (token ID) values. Check on every request. Use Redis with TTL matching the token expiration so entries auto-clean. Adds latency but gives instant revocation.
  • Token families. Group all refresh tokens from a single login session into a family. If reuse is detected (a rotated-out token is presented), revoke the entire family. This catches theft of refresh tokens.

What are the most common JWT vulnerabilities?

  • alg: "none" attack. Attacker sets the algorithm to none and strips the signature. Vulnerable libraries accept the token as valid. Fix: always specify the expected algorithm in verification options.
  • HMAC/RSA confusion. Server expects RS256 (asymmetric) but attacker sends HS256 (symmetric) using the public key as the HMAC secret. Since the public key is known, the attacker can forge tokens. Fix: explicitly set algorithms: ['RS256'] in verification — never let the token header dictate the algorithm.
  • kid injection. The kid (Key ID) header parameter is used to look up the verification key. If this value is passed unsanitized into a file path or SQL query, it enables directory traversal or SQL injection. Fix: validate kid against an allowlist.
  • Missing expiration. Tokens without exp never expire. A single leaked token grants permanent access. Fix: always set and enforce expiration.

Which JWT libraries should you use?

Use well-maintained, audited libraries that default to secure behavior. Avoid libraries that allow alg: "none" or do not enforce algorithm specification.

LanguageLibraryNotes
Node.jsjoseWeb Crypto API, Edge-compatible, no native deps
PythonPyJWTWidely adopted, supports RS/ES/PS algorithms
Gogolang-jwt/jwtSuccessor to dgrijalva/jwt-go, actively maintained

Quick reference checklist

  • Always verify signatures with an explicit algorithm allowlist
  • Set access token expiration to 5-15 minutes
  • Use RS256 or ES256 in multi-service architectures
  • Never put sensitive data in the payload
  • Rotate refresh tokens on every use
  • Store tokens in HttpOnly cookies or in-memory, not localStorage
  • Include jti if you need revocation capability
  • Validate iss, aud, and exp on every verification call

References

Inspect and debug your tokens with the JWT Debugger, or learn the fundamentals in What is a JWT?

Was this helpful?

Read next

What is a JWT? A Developer Guide to JSON Web Tokens

Learn how JSON Web Tokens work — header, payload, and signature structure, typical auth use cases, common pitfalls, and best practices for secure usage.

Continue →

Frequently Asked Questions

Should I use HS256 or RS256 for JWT signing?

Use RS256 (or ES256) in production. Asymmetric algorithms let you distribute the public key for verification without exposing the private signing key. HS256 requires sharing the same secret between signer and verifier, which is a security risk in distributed systems.

Where should I store JWTs?

HttpOnly cookies with the Secure and SameSite flags are the safest option for web applications — they are not accessible to JavaScript. Avoid localStorage (vulnerable to XSS). For mobile apps, use the platform secure storage (Keychain on iOS, Keystore on Android).

How do I revoke a JWT?

JWTs are stateless, so true revocation requires server-side state. Options: use short-lived access tokens (5-15 minutes) with refresh tokens, maintain a deny list of revoked token IDs (jti claim), or use token families where revoking one token invalidates the entire family.

Can I encrypt a JWT payload?

Yes — use JWE (JSON Web Encryption, RFC 7516) instead of plain JWS. JWE encrypts the payload so it is not readable by clients. For most applications, switching to opaque session tokens stored server-side is simpler than wiring up JWE; reach for JWE only when you genuinely need a self-contained encrypted token.

Stay up to date

Get notified about new guides, tools, and cheatsheets.