Skip to content
Free Tool Arena

Developers & Technical · Guide · Developer Utilities

How to use JWT tokens securely

JWT anatomy, HS256 vs RS256, the 'alg: none' attack, expiration strategy, storage (localStorage vs httpOnly cookies), revocation patterns, and claim validation.

Updated April 2026 · 6 min read

JSON Web Tokens are everywhere — OAuth flows, API auth, session management, service-to-service calls. They’re also one of the most-misused pieces of auth infrastructure. The spec is small; the mistakes are many: storing secrets in the payload, trusting the “alg” header, leaving tokens alive too long, shipping them to localStorage where any XSS can steal them. This guide walks through what a JWT actually is, how to verify one properly, the security pitfalls with real-world consequences, and when to use JWTs vs. a plain session.

Advertisement

The anatomy — three parts, dot-separated

A JWT looks like xxxxx.yyyyy.zzzzz. Each segment is Base64URL-encoded.

Header (first segment): JSON describing the signing algorithm (alg) and token type (typ). Example: {"alg":"HS256","typ":"JWT"}.

Payload (second segment): JSON with claims — the actual data the token carries. Standard claims: iss (issuer), sub (subject), aud (audience), exp (expiration), iat (issued at), nbf (not before), jti (JWT ID).

Signature (third segment): cryptographic proof that the token hasn’t been tampered with. Computed over header + payload using a secret (HMAC) or private key (RSA/ ECDSA).

Crucial: the payload is not encrypted. It’s just Base64-encoded. Anyone with the token can read the claims. Never put secrets, passwords, or PII you don’t want exposed in a JWT payload.

How signing works — HS256 vs RS256

HS256 (HMAC with SHA-256): symmetric — the same secret signs and verifies. Fast, simple. Problem: every service that verifies needs the secret, and anyone with the secret can mint tokens. Use for simple setups where one party signs and verifies.

RS256 (RSA with SHA-256) / ES256 (ECDSA):asymmetric — private key signs, public key verifies. Issuer holds private key; consumers only need the public key. The choice for multi-service or third-party consumption.

Rule: if more than one service needs to verify tokens, use RS256 and distribute the public key (typically via a JWKS endpoint). Don’t share HS256 secrets across teams or services.

The “alg: none” attack

Historic vulnerability: some JWT libraries accepted tokens with alg: "none" and skipped signature verification. An attacker crafts a token with arbitrary claims and no signature; a vulnerable server accepts it.

Defense: always specify the expected algorithm in your verify call. jwt.verify(token, secret, { algorithms: ['RS256'] }). Never let the server trust the alg header from the token itself.

Related: the “HMAC with public key as secret” attack. Server expects RS256 (public key verifies); attacker sends HS256 token signed with the server’s public key as the secret; vulnerable libraries use the public key as an HMAC secret and verify. Fix: lock algorithms explicitly.

Expiration — the most-overlooked field

A JWT without exp is a bearer credential that never dies. Set an expiration on every token.

Access tokens: short-lived. 5-15 minutes. If leaked, the damage window is small.

Refresh tokens: longer (hours to days). Used only to get new access tokens. Stored more carefully (httpOnly cookie, not localStorage). Revokable.

Clock skew: accept tokens issued up to ~60 seconds in the future to tolerate clock differences between issuer and verifier. Most libraries have a clock-tolerance option.

Storage — the front-end question

localStorage: easy, but any XSS vulnerability steals the token instantly. Avoid for anything sensitive.

sessionStorage: cleared on tab close. Same XSS risk as localStorage.

httpOnly, Secure, SameSite=Lax cookie: not accessible from JavaScript, survives tab close, automatically sent on same-site requests. Best default for web apps.

In-memory (JavaScript variable): lost on page reload but safest from XSS. Often paired with a refresh-token cookie to silently restore auth.

For mobile apps: secure enclave (iOS Keychain, Android Keystore). Never plain SharedPreferences or UserDefaults for tokens.

JWT vs server-side sessions — when to pick which

Server-side sessions (session ID cookie + server store): revocable instantly, invisible to client, simple. Use for most monoliths and same-origin web apps.

JWT: stateless verification, distributable, scales horizontally without a session store. Use when you need multiple services to verify auth without talking to a central store, or when issuing tokens to third-party consumers (APIs, OAuth integrations).

A classic mistake: using JWT for an app with a single backend and no cross-service needs. You get all the downsides (hard to revoke, token-bloat claims) with none of the benefits.

Revocation — the hard problem

Stateless tokens can’t easily be revoked. If a token leaks, short expirations are your main defense.

For true revocation, options:

Denylist: maintain a list of revoked jti values in Redis until they naturally expire. Gives revocation but adds a network call per verify.

Short expiration + refresh token: access tokens expire quickly (5-15 min); refresh tokens can be revoked on logout. Revocation propagates within the access-token lifetime.

User-level version counter: include a “session version” in the JWT; server bumps the version on logout/password-change; tokens with old versions rejected. Still requires a lookup but minimal.

Claim validation — don’t trust the decode

Verifying the signature is only step one. After signature passes, validate:

exp: token not expired.

nbf: token already usable.

iss: issued by the expected party.

aud: intended for your service. Critical for multi-service setups — a token issued for service A should not authenticate to service B.

sub: identifies the user, but verify the user still exists and is active (banned, deleted, password-changed accounts).

Common mistakes

1. Putting secrets in the payload. Passwords, API keys, private data. The payload is readable by anyone.

2. Using weak HS256 secrets. “secret” or “changeme” are brute-forceable. Use a 256+ bit random secret.

3. Not rotating keys. Keys leak over time. Rotate periodically; use a kid (key ID) in the header to support multiple active keys during rotation.

4. Logging tokens. Tokens in access logs, Sentry breadcrumbs, or error reports are a data leak. Scrub Authorization headers and JWT-looking strings from telemetry.

5. Treating unsigned tokens as valid. Parse- without-verify code paths (for “just reading the claims”) accidentally get used for authorization decisions. Separate decode-only vs. verify-and-trust code paths.

6. Accepting tokens in URL parameters. URLs end up in browser history, server logs, referrers. Always send in Authorization header or cookie.

The quick “is my JWT OK” checklist

Signed with RS256 or HS256 (not “none”).

Has exp. Ideally short (minutes for access tokens).

Payload has no secrets.

Verifier explicitly specifies expected algorithm.

aud / iss validated.

Transported via Authorization header or secure cookie.

Refresh-token revocation path exists.

Run the numbers

Inspect the claims inside any JWT with the JWT decoder. Pair with the Base64 encoder/decoder for manual segment decoding, and the hash generator when generating or comparing HMAC signatures for HS256 tokens.

Advertisement

Found this useful?Email