~/guides/-blog-jwt-security-best-practices-
guides · Security

JWT Security Best Practices: What Most Tutorials Miss

The JWT pitfalls that most auth tutorials never cover — algorithm confusion attacks, missing expiry, insecure storage, and how to fix each one.

last updated · June 13, 2026by @vultio

What JWTs are — and what they are not

A JSON Web Token is a compact, URL-safe format for transmitting claims between two parties. The three dot-separated parts — header, payload, and signature — are Base64URL-encoded, not encrypted. That distinction matters more than most tutorials acknowledge. Anyone who intercepts a JWT can read every claim inside it without knowing the signing key. The signature only proves the token has not been tampered with and was issued by someone holding the correct key.

JWTs are also stateless by design. The server does not store them. Verification happens entirely from the token itself and the key material the server already holds. That is their efficiency advantage — and their chief operational risk. Once issued, a JWT cannot be individually revoked until it expires. There is no central store to update. If a token is stolen, the attacker has access until the expiry time passes.

JWTs are not sessions. They do not replace server-side session state in all situations. They are not secrets. They should not carry secrets. Understanding these boundaries is the first step to using them safely.

The algorithm confusion attack

One of the most exploited JWT vulnerabilities in real-world CVEs is algorithm confusion. The JWT header contains an alg field that tells the verifier which algorithm was used to sign the token. Early JWT libraries trusted this field completely — meaning an attacker could forge a token and instruct the server to verify it differently than intended.

The most dangerous variant is the RS256-to-HS256 confusion attack. When a server uses RS256 (asymmetric), it signs tokens with a private key and verifies them with the public key. The public key is — by definition — public. An attacker who knows the public key can craft a token, change the header to alg: HS256(symmetric), and sign it using the public key as the HMAC secret. A vulnerable library will then verify the signature using the same public key as the HMAC secret and accept the forged token.

This exact attack affected Auth0, python-jose, and several other widely-used libraries between 2015 and 2022. CVE-2022-21449 (the Java "psychic signatures" bug) and related algorithm-handling issues have appeared across Go, Python, Ruby, and PHP JWT libraries.

The fix: Never read the algorithm from the token header at verification time. Specify the expected algorithm explicitly in your library configuration and reject anything that does not match. Most modern libraries provide an algorithms allowlist parameter — use it.

// Node.js — explicitly specify the allowed algorithm
import jwt from 'jsonwebtoken';

// WRONG: trusting the token's own alg header
const decoded = jwt.verify(token, secret);

// CORRECT: allowlist exactly one algorithm
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });

// For RS256, pass the public key and lock the algorithm
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });

The "none" algorithm vulnerability

The JWT specification includes a valid algorithm value of "none", intended for situations where integrity is guaranteed by the transport layer. In practice, this has been a prolific source of vulnerabilities. Dozens of libraries, when presented with a token bearing alg: none, would skip signature verification entirely and accept any payload as legitimate.

The exploit is trivial. An attacker takes any valid token, decodes the header, changes the algfield to "none" (or "None", "NONE", or "nOnE" to bypass case-sensitive string checks), modifies the payload to escalate privileges, removes the signature, and submits the result. Vulnerable servers accept it without question.

// A forged "none" token — header.payload. (empty signature)
// Header (decoded): { "alg": "none", "typ": "JWT" }
// Payload (decoded): { "sub": "1234", "role": "admin", "exp": 9999999999 }
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0
.eyJzdWIiOiIxMjM0Iiwicm9sZSI6ImFkbWluIiwiZXhwIjo5OTk5OTk5OTk5fQ
.

The fix: Never accept alg: none in any production context. Explicitly allowlist the algorithms your server accepts and reject everything else, including case variations of "none". This is the same defence as algorithm confusion — a strict allowlist makes both attacks impossible.

Missing or dangerously long expiry

Because JWTs are stateless, expiration is the primary revocation mechanism. A token with no expclaim is valid forever. A token with an expiry set to 30 days or a year is effectively permanent for most threat scenarios. If a token is stolen — through XSS, a leaked log, a compromised device, or an API response cached somewhere it should not be — the attacker has that entire window to use it.

The standard recommendation for access tokens is 15 minutes. Refresh tokens, which are stored more carefully server-side and can be revoked, can be longer — hours to days depending on the application's risk profile. The access token lifetime should be short enough that a stolen token expires before it causes significant damage.

// Signing with a short expiry
const token = jwt.sign(
  { sub: userId, role: 'user' },
  process.env.JWT_SECRET,
  {
    expiresIn: '15m',   // access token: short-lived
    algorithm: 'HS256'
  }
);

// Refresh token stored server-side, longer-lived, revocable
const refreshToken = jwt.sign(
  { sub: userId, type: 'refresh' },
  process.env.JWT_REFRESH_SECRET,
  { expiresIn: '7d', algorithm: 'HS256' }
);

Storing JWTs insecurely

Where a token is stored determines what can steal it. The two common choices are localStorageand HttpOnly cookies, and they have opposite vulnerability profiles.

localStorage is readable by any JavaScript running on the page. That makes tokens stored there accessible to cross-site scripting (XSS) attacks — including attacks via compromised third-party scripts, injected ad content, or browser extensions. Any script that executes in the page origin can calllocalStorage.getItem('token') and exfiltrate the credential.

HttpOnly cookies are not accessible to JavaScript at all. They are sent automatically with requests to the matching domain, and because they cannot be read by scripts, XSS attacks cannot steal them directly. The tradeoff is CSRF (cross-site request forgery) risk — automatic cookie inclusion means a malicious page can trigger authenticated requests. Mitigate this with SameSite=Strict orSameSite=Lax, plus a CSRF token for state-changing endpoints.

localStorage: simple to implement, vulnerable to XSS token theft, suitable only for low-risk or non-auth data.
sessionStorage: same XSS risk as localStorage, clears on tab close, not meaningfully more secure.
HttpOnly cookie with SameSite=Strict: XSS cannot read the token, CSRF risk mitigated by SameSite — the recommended default for web applications.
In-memory (JS variable): no persistence across page loads, safest against XSS, impractical for many SPAs.

Not validating the audience claim

The aud (audience) claim specifies which service or services the token is intended for. If you run multiple services under one identity provider — an API gateway, an internal admin service, a reporting service — and none of them check the audience, a token issued for the reporting service is also valid on the admin service.

Token reuse across services is a real attack vector in microservice architectures. A lower-privilege service with weaker input validation is compromised. The attacker extracts a token and replays it against a higher-value internal service that trusts the same issuer but does not check aud. The signature is valid, the expiry has not passed, and access is granted.

// Signing — include a specific audience
const token = jwt.sign(
  { sub: userId },
  secret,
  { audience: 'https://api.example.com/v1/', expiresIn: '15m' }
);

// Verifying — enforce the expected audience
const decoded = jwt.verify(token, secret, {
  algorithms: ['HS256'],
  audience: 'https://api.example.com/v1/'
});
// Throws JsonWebTokenError if aud does not match

Leaking sensitive data in the payload

JWTs are Base64URL-encoded, not encrypted. The payload is readable by anyone who holds the token — the client, any proxy in the request path, a logging system that captured the Authorization header, or an attacker who intercepted the token. This is not a flaw; it is a design property. But it is one that teams routinely treat incorrectly.

Common mistakes include embedding email addresses, full names, phone numbers, internal account numbers, billing plan details, feature flags with business-sensitive names, or database IDs that expose the underlying schema. Every one of those claims is visible to the client and to any intermediate system that logs the raw token.

JWT payloads should carry only the minimum claims needed for the verification decision: subject (sub), issuer (iss), audience (aud), expiry (exp), and any coarse-grained roles the server needs to make access decisions. Anything sensitive beyond that should stay in the database and be fetched server-side using the subject identifier as the lookup key.

Key management mistakes

The signing key is the root of trust for every JWT your application issues. Weak key management makes all other defences less meaningful.

Hardcoded secrets in source code

A secret committed to a repository — even a private one — is compromised. Git history persists indefinitely, and repository access is often broader than production secrets should allow. Use environment variables or a secrets manager and keep signing keys out of source control entirely.

The same key across all environments

Using the same JWT secret in development, staging, and production means a developer machine breach can yield production-valid tokens. Environment-specific keys limit blast radius. A token issued in staging should be cryptographically invalid in production.

No key rotation plan

Symmetric HMAC keys and asymmetric private keys should be rotated periodically and immediately after a suspected compromise. Design your system to support rotation: publish a JWKS endpoint for asymmetric keys, include a key ID (kid) in token headers so verifiers can select the correct key, and support a brief overlap window during transitions.

Weak HMAC secrets

HS256 security depends entirely on secret entropy. A short or guessable secret can be brute-forced offline against a captured token. Secrets should be at least 256 bits (32 bytes) of cryptographically random data generated by a CSPRNG — not a password, not a UUID, not a human-readable string.

How to inspect a JWT without running code

During debugging, incident response, or code review, you often need to inspect a JWT immediately — without setting up a local script or sharing the token with a third-party web service. The JWT Decoder on Vultio decodes any token client-side directly in the browser. No token data leaves your machine. You can paste a token and immediately see the algorithm, all claims, expiry timestamps in human-readable form, and whether the structure is well-formed.

This is useful for verifying that the correct claims are present after a code change, checking whether an expired token is the source of a 401 error, or reviewing what payload is being sent from a client you do not control. Remember that decoding is not verification — the decoder tells you what the token says, not whether to trust it. For trust decisions, always verify server-side with the correct key and claim rules.

Pre-deployment checklist: 8 things to verify

Before shipping JWT-based authentication to production, run through these eight checks. Each one covers a real attack vector that has appeared in production incidents or public CVEs.

1. Algorithm allowlist is explicit

Your verification code specifies exactly which algorithms are accepted and rejects everything else. The algorithm is never read from the token header without validation against the allowlist.

2. alg:none is rejected

Your library or verification code explicitly rejects any token presenting alg:none, regardless of casing. Confirmed by passing a crafted none-algorithm token and observing rejection.

3. Expiry is enforced and short

Access tokens expire in 15 minutes or less. The exp claim is enforced on every request, not just at login. Tokens with no exp claim are rejected.

4. Audience is validated

Every service checks the aud claim against its own identifier. A token issued for one service is rejected by all others.

5. Signing keys are environment-specific and stored securely

Development, staging, and production use different keys. Keys live in environment variables or a secrets manager — never in source code or committed configuration files.

6. Tokens contain no sensitive PII

The payload carries only claims needed for access decisions. Email addresses, names, payment details, and schema-exposing identifiers are absent from the token.

7. Client storage uses HttpOnly cookies with SameSite protection

Tokens are not stored in localStorage or sessionStorage in web applications. HttpOnly, Secure, and SameSite=Strict cookie attributes are set.

8. Key rotation is operationally supported

The system supports issuing tokens signed with a new key while still verifying tokens signed with the previous key during the transition window. A JWKS endpoint or equivalent mechanism is in place for asymmetric keys.