JWT Security Best Practices: What Never to Store in a Token
JSON Web Tokens are one of the most widely used authentication mechanisms in modern web development. They're also one of the most commonly misused. Developers routinely store passwords, PII, API secrets, and sensitive application data in JWT payloads — without realizing that anyone who holds the token can read every field instantly, no key required.
What a JWT actually is
A JWT is a compact string with three Base64URL-encoded parts separated by dots: a header, a payload, and a signature. Here's what each part does:
The header declares the token type and the signing algorithm:
{
"alg": "HS256",
"typ": "JWT"
}The payload contains the claims — key-value pairs asserting things about the user or session:
{
"sub": "user_12345",
"email": "alice@example.com",
"role": "admin",
"exp": 1748000000,
"iat": 1747913600
}The signature is produced by signing the Base64URL-encoded header and payload with a secret or private key. It proves the token hasn't been tampered with since it was issued.
The critical point: signing is not encryption. The payload is only encoded, not encrypted. Decoding it requires nothing but a Base64URL decoder — which is available in every browser, every language, and at hundreds of online tools. The signature proves integrity (the data hasn't changed), but it provides zero confidentiality.
What never to put in the payload
Passwords and secrets
Never include a user's password — hashed or otherwise — in a JWT payload. Never include API keys, signing secrets, database credentials, or encryption keys. These are secrets precisely because they must remain confidential, and a JWT payload is not confidential.
If a JWT is stored in localStorage (a common pattern), any JavaScript on the page can read it. Third-party analytics scripts, chat widgets, A/B testing libraries — all of them have access to localStorage. A single compromised dependency can exfiltrate every JWT stored in your users' browsers.
Rule: If you would never log it in plaintext, don't put it in a JWT payload. Tokens appear in server logs, CDN access logs, browser history, and proxy caches.
Sensitive personal information
Avoid including Social Security numbers, credit card details, medical records, government IDs, or any PII beyond the minimum required for session management. A user ID (sub) and a role claim is almost always sufficient. Anything more than that increases risk with little benefit — the full user profile can always be fetched from the database using the user ID.
Large amounts of data
JWTs are sent as an HTTP header on every request: Authorization: Bearer <token>. A 4KB JWT on an API that handles 10,000 requests per minute adds 40MB of header data per minute — every minute. Keep payloads small. Standard practice is to store only a user ID, a role or permission set, and the standard time claims (exp, iat, nbf).
Algorithm selection
The signing algorithm you choose matters significantly for security architecture:
HS256 (symmetric)
HMAC-SHA256 uses a single shared secret for both signing and verification. The same key that issues tokens can also verify them. This is appropriate for monolithic applications where one service handles both token issuance and verification. The risk: any service that can verify tokens can also issue them. If a downstream service is compromised, the attacker can forge tokens.
RS256 (asymmetric)
RSA-SHA256 uses a private key to sign tokens and a public key to verify them. Multiple microservices can verify tokens by fetching the public key from a JWKS endpoint — without ever having access to the signing key. This is the correct choice for distributed systems. A compromised verification service can't forge new tokens because it only has the public key.
The none algorithm vulnerability
Early JWT libraries supported an alg: none mode where the signature was omitted entirely. An attacker could modify a token's payload, set the algorithm to none, and submit it as a valid token — and some vulnerable servers would accept it.
Always validate the algorithm on the server side. Never accept a token that uses an algorithm you didn't expect. Reject any token with alg: none outright, regardless of what your library's default behavior is.
Secret strength for HS256
For HS256, the security of every token your system issues depends entirely on the strength of your secret. A weak secret can be brute-forced: an attacker intercepts a token, then tries signing the same header and payload with common strings until the signature matches.
Requirements for a secure HS256 secret:
- At least 256 bits (32 bytes) of entropy
- Generated by a cryptographically secure random number generator (CSPRNG)
- Never derived from a human-readable string
Generate one with OpenSSL:
openssl rand -base64 32Store it in an environment variable, never hardcode it, and rotate it periodically. When you rotate, all existing tokens are immediately invalidated — factor this into your expiry and refresh token strategy.
Expiry and refresh token strategy
Every JWT should include an exp (expiration) claim. Tokens without expiry are valid indefinitely — if one is stolen, it works forever.
The standard pattern is short-lived access tokens paired with longer-lived refresh tokens:
- Access token: expires in 15 minutes to 1 hour. Sent on every API request.
- Refresh token: expires in 7–30 days. Used only to obtain a new access token. Stored more securely than the access token.
When the access token expires, the client sends the refresh token to a dedicated endpoint to get a new access token. If the refresh token itself is expired or revoked, the user must authenticate again. This pattern limits the blast radius of a stolen token — a leaked access token is only useful for minutes, not indefinitely.
Browser storage: localStorage vs cookies
localStorage is convenient and survives page refreshes and browser restarts. But it's accessible by any JavaScript running on your page — including third-party scripts. This makes it vulnerable to XSS attacks. If an attacker injects JavaScript into your page, they can steal every token in localStorage.
HttpOnly cookies are not accessible from JavaScript at all. The browser sends them automatically on requests to the appropriate domain, but document.cookie cannot read them. Combined with the Secure flag (HTTPS only) and SameSite=Strict (no cross-site sending), HttpOnly cookies are significantly more resistant to token theft.
The trade-off: cookies require CSRF protection. With SameSite=Strict, most CSRF vectors are already mitigated, but your server should also validate a CSRF token for state-changing requests.
Best practice: Store access tokens in memory (a JavaScript variable) and refresh tokens in HttpOnly cookies. Access tokens in memory are lost on page refresh — the refresh token cookie silently obtains a new one. This combines the XSS resistance of HttpOnly cookies with the CSRF safety of not sending the access token automatically.
Token revocation
JWTs are stateless by design — the server doesn't store them. This means revoking a specific token before it expires is non-trivial. Common approaches:
- Short expiry: The simplest approach. Stolen tokens become useless quickly.
- Blocklist: Store revoked token IDs (the
jticlaim) in a fast store like Redis. Check each token against the blocklist on every request. Adds latency but enables immediate revocation. - Refresh token rotation: Each use of a refresh token issues a new refresh token and invalidates the old one. Reuse of a revoked refresh token triggers revocation of the entire session.
// summary
- JWT payloads are Base64URL-encoded, not encrypted — anyone with the token can read them
- Never store passwords, API keys, secrets, or sensitive PII in the payload
- Keep payloads small — user ID, role, and time claims are usually sufficient
- Use RS256 for distributed systems, HS256 for simple monoliths
- Always validate the algorithm server-side; reject
alg: none - Use a 256-bit randomly generated secret for HS256
- Set short expiry (15–60 minutes) for access tokens; use refresh tokens for re-authentication
- Prefer HttpOnly cookies over localStorage for browser storage