6 Misunderstood Details of JWT

6 Misunderstood Details of JWT

Don’t put private data in JWTs. Also pin algorithms, rotate refresh tokens with reuse detection, opaque vs JWT, hex/Base64 secrets, and real revocation.

JSON Web Tokens look simple: three Base64url segments, a signature, no session table. That simplicity hides six details teams still get wrong—private data in the payload, algorithm enforcement, refresh-token rotation, opaque versus JWT, the hex/Base64 secret trap, and revocation. Miss any one and “stateless auth” becomes a data leak, a forged session, or a logout button that does nothing.

1. Do not store private data in a JWT

A JWT is signed, not encrypted. Header and payload are Base64url. Anyone who holds the token—browser extension, log aggregator, CDN cache, support paste, stolen laptop—can decode every claim on jwt.io in one click. The signature only proves the bytes were not altered. It does not hide them.

Never put in the payload:

  • passwords, password hashes, reset tokens
  • session secrets, API keys, bank or card data
  • national IDs, medical notes, precise location
  • internal “secret” flags you would not show the user

Safe claims are identifiers and authorization hints the API already needs: sub, iss, aud, exp, jti, coarse roles or scopes. If a claim would be a GDPR/CCPA incident when a JWT leaks, it does not belong in the token. Fetch that data from your API after you trust sub.

JWE (encrypted JWT) exists, but it is a different product: key management, payload size, and debugging cost go up. Default stack is JWS. Treat the postcard as public.

Also: localStorage is not a vault. XSS reads the token and reads the claims. Prefer memory or an HttpOnly, Secure, SameSite cookie for the credential—and still keep the payload boring.

2. Algorithm enforcement: never trust alg

The header is attacker-controlled. If the verifier picks a code path from "alg", two failures follow.

alg: none. A naive parser accepts header.payload. with an empty signature. Modern libraries reject none unless you opt in; hand-rolled code and None/NONE case bugs still ship.

RS256 → HS256 confusion. The API expects RSA. An attacker fetches your public key from JWKS, sets "alg": "HS256", and HMACs the token using that public key as the secret. verify(token, publicKey) without a pinned algorithm treats PEM bytes as an HMAC key and accepts the forgery. Still disclosed in 2026.

Pin algorithms in code, never from the token:

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

Do not pass HMAC and RSA algorithms into the same verify call with the same key material.

3. Refresh token rotation needs reuse detection

Short-lived access JWTs (5–15 minutes) plus a long-lived refresh token is the usual pattern. Rotation means every refresh burns the old refresh token and issues a new pair.

Rotation alone is not enough. If the attacker refreshes first, they hold the new token and the victim’s next refresh looks like reuse. If you only reject the old token and leave the attacker’s new one alive, theft wins. The correct policy is family revocation: on reuse, kill every refresh token from that login and force re-auth. RFC 9700 wants public clients to rotate with replay detection, or use sender-constrained tokens (DPoP / mTLS).

Handle lost responses: the server rotated, the client never got the new token, retries the old one. A short grace window or compare-and-set store avoids locking out honest mobile apps while still detecting two holders.

4. Opaque vs JWT: two different jobs

A JWT is a self-contained assertion. Any service with the verifying key can check it offline. That is why gateways like them—and why they make a poor refresh token (and a poor place to stash PII; see §1).

An opaque token is a random lookup key. The authorization server holds subject, scopes, expiry, revoked-or-not. Introspection answers “is this still good?”

Use JWTs for access tokens when you want scaled APIs and can live with a few minutes of delay on revoke. Use opaque tokens for refresh tokens and for credentials you must kill instantly. A 30-day JWT refresh token in localStorage is long-lived, readable, and hard to revoke.

5. The secret encoding trap: hex vs Base64

HS256 keys are bytes. Hex and Base64 are only spellings of those bytes. A 32-byte key is 64 hex characters or ~44 Base64 characters—same entropy. Outages happen when issuer and verifier disagree on decoding.

jwt.io’s “secret base64 encoded” checkbox means: decode to bytes, then HMAC. If Node uses the Base64 string as UTF-8 key bytes while another service decodes first, signatures diverge—or you ship a different key than you tested.

  • Generate crypto.randomBytes(32) (or stronger). Store one encoding in the secret manager.
  • Document whether the env var is raw UTF-8, hex, or Base64.
  • Never mix HMAC secrets and RSA PEMs in one config key.
  • HS256 needs ≥256 bits of randomness—not "supersecret", not a hex string of 16 bytes you forgot to decode.

6. Revocation: JWTs are valid until exp

A signed JWT is true until it expires. Logout that only clears the browser leaves a stolen Bearer token working—and if you stuffed private claims in it, the thief has that data too.

Practical options, rising cost:

  1. Short exp (5–15 min) and accept the window.
  2. Deny-list of jti values until expiry.
  3. Version claim (ver / tv) checked against the user record; “sign out all” increments it.
  4. Introspect or use opaque access tokens on high-risk routes.

You cannot have fully offline verification and instant global revoke. Pair short JWT access tokens (public, boring claims) with rotatable, server-stored refresh tokens so a leak is minutes of access, not a week of data.

What to remember

The payload is public—identifiers only, no private data. Pin algorithms. Rotate refresh tokens and revoke the family on reuse. JWT for access, opaque for refresh and kill-switches. Declare hex vs Base64 once. Assume every JWT lives until exp unless you built a list or a version check.

JWT is a signed postcard, not a vault and not a session database.