JSON Web Tokens (JWTs) power authentication for a huge fraction of modern web applications. They're also responsible for a lot of avoidable security incidents. Here are the mistakes we keep seeing, and how to avoid them.
JWT basics — what's in a token
A JWT is three base64url-encoded parts joined by dots: header.payload.signature. The header declares the signing algorithm. The payload contains claims (user ID, expiration, etc.). The signature is a cryptographic signature over the header and payload.
Crucially: JWTs are encoded, not encrypted. Anyone with the token can read the contents. Only the signature is protected — modifying the payload invalidates the signature, but the payload itself is readable. Treat JWT contents as if you posted them on a billboard.
Mistake #1: storing secrets in the payload
Because the payload is readable, anything you put in it is visible to anyone with the token. Password? Visible. Credit card number? Visible. Internal user IDs? Visible.
Fix: Put only the minimum needed in the payload — typically just a user identifier and roles. Never include passwords, secret keys, financial details, or PII beyond what's strictly needed.
Mistake #2: accepting alg:none
The classic JWT attack. The header includes an alg field that names the signing algorithm. If your server accepts "alg": "none", an attacker can construct a token with an arbitrary payload and an empty signature — and your server validates it.
Fix: Always validate the algorithm on the server side. Use a library that lets you specify the expected algorithm explicitly:
jwt.verify(token, secret, { algorithms: ["HS256"] })
Never trust the alg field from the token itself — that's user input.
Mistake #3: confusing HMAC and RSA keys
Another classic. Your server uses RS256 (RSA) with a public key for verification. An attacker constructs a token signed with HS256 (HMAC), using your public key as the HMAC secret. If your server picks the algorithm based on the token header, it interprets the public key as an HMAC secret — and validation passes.
Fix: Same as above. Explicitly specify the expected algorithm. Never let the token tell you what algorithm to use.
Mistake #4: weak HMAC secrets
HMAC security depends entirely on the secret's entropy. A 16-character password is crackable offline. Once an attacker has any valid JWT, they can run an offline brute-force or dictionary attack — no rate limiting can save you.
Fix: For HMAC-signed tokens, the secret should be at least 32 random bytes (256 bits). Generate it with a cryptographically secure source:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Store the secret in a secrets manager (HashiCorp Vault, AWS Secrets Manager, similar) — never in source code or environment variables that get logged.
Mistake #5: long-lived tokens
It's tempting to issue tokens that last 30 days. Convenient — no re-authentication. But if a token is leaked (browser exploit, hostile network, misconfigured logging), the attacker has 30 days of access.
Fix: Issue short-lived access tokens (5-15 minutes). Pair them with longer-lived refresh tokens stored more securely. When the access token expires, the client exchanges the refresh token for a new access token. If a refresh token is compromised, you can revoke it server-side without affecting other tokens.
Mistake #6: not validating expiration
You'd think every JWT library checks exp automatically. They mostly do — but custom code that hand-decodes tokens (yes, this exists in production) frequently forgets to check.
Fix: Always use a battle-tested JWT library. If you must roll your own validation, the absolute minimum is: verify signature, check exp, check iss (issuer) matches what you expect, check aud (audience) if your tokens are scoped.
Mistake #7: storing JWTs in localStorage
localStorage is accessible to any JavaScript running in your page. If you have an XSS vulnerability anywhere, the attacker reads the token and uses it as the legitimate user. localStorage has no XSS protection.
Fix: Store tokens in httpOnly cookies. They're not accessible to JavaScript, which means XSS can't steal them. Pair with the SameSite=Strict or SameSite=Lax attribute to prevent CSRF. The downside: you need a separate CSRF protection mechanism (CSRF tokens or origin checks) for state-changing requests.
An alternative pattern: store short-lived access tokens in memory only (lost on tab close), with refresh tokens in httpOnly cookies. This minimizes blast radius if either is compromised.
Mistake #8: putting tokens in URLs
URLs end up in browser history, server logs, referrer headers, and bookmark sync services. A token in a URL is a token leaked to every system that touches that URL.
Fix: Never put tokens in query parameters. Always send via the Authorization header or an httpOnly cookie.
Mistake #9: no key rotation
If your signing key is compromised (insider threat, repository leak, server breach), every valid token issued with that key remains valid until expiration. Without rotation, recovery means invalidating all sessions.
Fix: Build key rotation in from day one. Use the kid (key ID) header to identify which key signed each token. Maintain multiple active keys and rotate them periodically. On rotation, old tokens remain valid until they expire naturally.
Mistake #10: trusting client-supplied claims
"role": "admin" in the payload doesn't make you admin. It makes you whoever the issuer of the token chose to call admin. If your application sets roles based on JWT claims without checking whether the token's issuer is authorized to grant those roles, an attacker can mint their own admin token from a token-issuing endpoint they're allowed to call.
Fix: Treat JWT claims as authenticated assertions from the issuer — no more. If you have multiple token issuers, validate iss against an allowlist. If you have role/permission data, look it up from your own authoritative source on every request, not from the token.
The minimum viable JWT setup
Pulling it together, here's a reasonable starting configuration:
- Algorithm: HS256 for single-service, RS256 or ES256 for distributed
- HMAC secret: 32 random bytes from a secrets manager
- Access token TTL: 15 minutes
- Refresh token TTL: 7 days, stored in
httpOnly+Securecookie - Issuer: your service identity, validated on every request
- Audience: validated against your service's expected audience
- Library: a maintained one (jose, jsonwebtoken, PyJWT) — not hand-rolled
- Algorithm validation: explicit allowlist passed to verify call
Inspecting JWTs safely
When debugging, you often need to decode a JWT to see its contents. Our JWT decoder does this entirely in your browser — no token data leaves your device. We deliberately don't offer signature verification because verifying requires the secret key, and pasting production secrets into a website is exactly the kind of mistake this article exists to prevent.
Wrap-up
Most JWT vulnerabilities are misuse, not flaws in the spec. Use a maintained library, validate the algorithm explicitly, keep tokens short-lived, store them in httpOnly cookies, and never put secrets in the payload. Do those five things and you're ahead of 90% of production JWT deployments.