Best Practices

AI Rules for JWT and Token Management

AI generates JWTs with no expiration, stores them in localStorage, and stuffs user data into the payload. Rules for token structure, signing algorithms, storage, rotation, and revocation.

8 min read·January 23, 2025

jwt.sign({ user: entireUserObject }, 'secret') — no expiration, PII in payload, stored in localStorage

RS256 signing, minimal claims, httpOnly cookies, refresh rotation, token families, revocation

AI Treats JWTs Like Magic Strings

AI generates JWTs with: no expiration (token works forever), HS256 with a short secret (weak signing), sensitive data in the payload (full user object, password hash, PII), localStorage storage (readable by any XSS attack), no refresh mechanism (user re-authenticates when the token somehow expires), and no revocation (you cannot invalidate a token before expiry). Every one of these patterns is a security vulnerability with a well-known solution.

Modern JWT management is: time-limited (access 15min, refresh 7d), asymmetrically signed (RS256 — public key verifies, private key signs), minimal payload (sub, iat, exp, role — nothing sensitive), securely stored (httpOnly secure SameSite cookie), refreshable (short access + rotating refresh), and revocable (server-side denylist or token family invalidation). AI generates none of these.

These rules cover: JWT structure and claims, signing algorithms, secure storage, refresh token rotation with families, and server-side revocation strategies.

Rule 1: Minimal JWT Claims

The rule: 'JWT payloads should contain: sub (user ID), iat (issued at), exp (expiration), and role or permissions. Nothing else. No email, no name, no profile data, no sensitive information. The JWT is a signed proof of identity and authorization — not a database cache. Any data in the JWT is readable by anyone who intercepts it (base64 encoded, not encrypted).'

For registered claims: 'Always include: sub (subject — user ID), iat (issued at — when the token was created), exp (expiration — when it becomes invalid), iss (issuer — your domain), aud (audience — intended recipient). These standard claims enable: token validation across services (iss + aud), expiration enforcement (exp), and user identification (sub) without custom parsing logic.'

AI generates: jwt.sign({ user: entireUserObject }, secret) — the entire user row from the database, including email, created date, and potentially sensitive fields. Anyone who intercepts or decodes the JWT (trivial — just base64) sees everything. Minimal claims mean: even if the token leaks, the attacker gets a user ID and an expiration — not PII.

  • sub, iat, exp, iss, aud — standard registered claims, always include these
  • role or permissions claim — only what is needed for authorization decisions
  • Never: email, name, address, phone, or any PII in the JWT payload
  • JWT is base64, not encrypted — anyone can decode and read the payload
  • Treat the JWT as a signed identity proof, not a portable user profile
💡 Base64, Not Encrypted

JWT payloads are base64-encoded — anyone can decode them. jwt.sign({ user: entireUserObject }) puts email, name, and potentially sensitive fields in a format anyone can read. Minimal claims (sub, exp, role) mean a leaked token reveals a user ID, not PII.

Rule 2: RS256 Asymmetric Signing

The rule: 'Use RS256 (RSA + SHA-256) for JWT signing. RS256 uses a private key to sign and a public key to verify. The private key stays on the auth server — never distributed. Any service can verify tokens using the public key without being able to create new ones. This is critical for microservices: services verify tokens without holding the signing secret.'

For HS256 vs RS256: 'HS256 (HMAC) uses a shared secret — every service that verifies tokens must have the same secret. If one service is compromised, the attacker can forge tokens for all services. RS256: only the auth server has the private key. Compromise of a verifying service does not enable token forgery. Use HS256 only for single-server applications where the same server signs and verifies.'

AI generates: jwt.sign(payload, 'my-secret-key') — HS256 with a hardcoded string. The secret is short (easily brute-forced), hardcoded (in source control), and shared (every service needs it). RS256 with a 2048-bit key pair: the private key is stored securely on the auth server, the public key is freely distributable, and key rotation is straightforward.

Rule 3: httpOnly Cookie Storage

The rule: 'Store JWTs in httpOnly, secure, SameSite=Strict cookies. httpOnly: JavaScript cannot read the cookie (XSS cannot steal the token). secure: cookie sent only over HTTPS (no plaintext transmission). SameSite=Strict: cookie not sent on cross-origin requests (CSRF protection). This combination makes the token inaccessible to client-side attacks — the browser handles it automatically.'

For access vs refresh token storage: 'Access token: short-lived (15min), can be stored in memory (JavaScript variable) — lost on page refresh, which is fine because the refresh token gets a new one. Refresh token: always in httpOnly secure cookie — long-lived, must be protected. Never store either token in localStorage or sessionStorage — both are readable by any JavaScript on the page.'

AI generates: localStorage.setItem('token', jwt) — readable by any XSS attack. One injected script, one stolen token, full account access. httpOnly cookies are invisible to JavaScript entirely. The browser sends the cookie with every request to the origin — no manual Authorization header needed. More secure and less code.

  • httpOnly — JavaScript cannot read it, XSS cannot steal it
  • secure — HTTPS only, no plaintext transmission
  • SameSite=Strict — not sent on cross-origin requests, CSRF protection
  • Access token in memory (variable) — refresh token in httpOnly cookie
  • Never localStorage or sessionStorage — both are XSS-readable
⚠️ localStorage = XSS Target

localStorage.setItem('token', jwt) — one XSS vulnerability and the attacker reads every stored token. httpOnly cookies are invisible to JavaScript entirely. The browser sends them automatically. More secure, less code, zero XSS exposure.

Rule 4: Refresh Token Rotation and Families

The rule: 'Rotate refresh tokens on every use: when a refresh token is presented, issue a new access token AND a new refresh token, then invalidate the old refresh token. This is refresh token rotation. Track token families: all refresh tokens descended from the same login form a family. If a revoked refresh token is presented, revoke the entire family — this means the token was stolen.'

For token family tracking: 'Each refresh token has a family ID (set on login, inherited through rotations) and a generation number (incremented on each rotation). Store in the database: { familyId, generation, token, userId, expiresAt, revoked }. On refresh: check the generation — if it is not the latest, someone is replaying an old token. Revoke the entire family and force re-authentication.'

AI generates a single refresh token that never rotates. If stolen, the attacker can refresh indefinitely — the legitimate user and attacker both hold valid tokens. With rotation, the first one to refresh wins — the other gets a revoked token error, and the entire family is invalidated. The user re-authenticates; the attacker is locked out.

ℹ️ First to Refresh Wins

Without rotation, a stolen refresh token works forever alongside the legitimate one. With rotation, the first to refresh gets a new token — the second gets a revoked-token error, triggering family-wide invalidation. The attacker is locked out.

Rule 5: Server-Side Token Revocation

The rule: 'JWTs are stateless — you cannot invalidate them before expiration without server-side state. Three strategies: (1) Short expiration (15min) — acceptable blast radius, no revocation needed. (2) Denylist — store revoked token JTIs (JWT ID) in Redis with TTL matching token expiration. Check denylist on every request. (3) Version counter — store a tokenVersion in the user record. Include version in the JWT. On revocation, increment the version — all existing tokens become invalid.'

For the denylist approach: 'On logout or security event: add the token JTI to a Redis set with TTL = token remaining lifetime. On every request: check if the JTI is in the denylist. Redis lookup is O(1) — sub-millisecond. The denylist is small because entries auto-expire when the token would have expired anyway. This adds minimal overhead for the ability to instantly revoke any token.'

AI generates no revocation mechanism. Logout clears the client-side token but the token still works server-side. If a user account is compromised, the admin cannot invalidate the attacker session — the token works until it expires (which may be never). Server-side revocation is the escape hatch that makes JWT systems manageable in production.

Complete JWT Management Rules Template

Consolidated rules for JWT and token management.

  • Minimal claims: sub, iat, exp, iss, aud, role — never PII in the payload
  • RS256 asymmetric signing — private key on auth server, public key for verification
  • httpOnly + secure + SameSite=Strict cookies — never localStorage
  • Access token in memory (15min), refresh token in httpOnly cookie (7d)
  • Rotate refresh tokens on every use — new token pair, old token revoked
  • Token families — replay of revoked token invalidates entire family
  • Server-side revocation: Redis denylist or user tokenVersion counter
  • Always include JTI (JWT ID) — enables individual token revocation