EncryptCodecencryptcodec
Blog/Security
SecurityMarch 16, 2026 · 9 min read

JWT Security Checklist for Backend Developers — Stop Making These Mistakes

A team ships a Node.js API with JWTs for auth. Six months later, a security researcher reports they can forge tokens for any user by sending "alg": "none" in the header. The fix is a one-line change. The embarrassment — and the audit that follows — is not.

JWT vulnerabilities aren't exotic. They're almost always caused by misreading the spec, trusting library defaults, or skipping claim validation under deadline pressure. Here's the checklist that prevents all of it.

JWT Anatomy — click a section to inspect it

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20iLCJyb2xlIjoiYWRtaW4iLCJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJhcGkuZXhhbXBsZS5jb20iLCJpYXQiOjE3MTAwMDAwMDAsImV4cCI6MTcxMDAwMzYwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
HEADER
Algorithm & token type
PAYLOAD
Claims & user data
sub:"user_123"
subject — who the token is about
email:"alice@example.com"
custom claim — not standard, treat as untrusted
role:"admin"
custom claim — verify server-side, never trust blindly
iss:"https://auth.example.com"
issuer — must match your expected auth server
aud:"api.example.com"
audience — must match your service identifier
iat:1710000000
issued at — Unix timestamp
exp:1710003600
expires at — Unix timestamp, always validate
SIGNATURE
Integrity proof

Base64url-decoded · Signature verified server-side only · Payload is readable without a key

Never accept alg: none

The none algorithm is technically valid per the original JWT spec — it means "unsigned, trust me." Most mature libraries disable it by default now, but not all, and misconfiguration still happens constantly.

Always explicitly specify the allowed algorithms when verifying a token. Don't let the token header dictate which algorithm your server uses. The attack is straightforward: an attacker takes a valid token, strips the signature, sets "alg": "none", and some libraries will happily verify it as valid.

import jwt from 'jsonwebtoken';

// WRONG — algorithm comes from the token header
const payload = jwt.verify(token, secret);

// CORRECT — explicitly whitelist the algorithm
const payload = jwt.verify(token, secret, { algorithms: ['HS256'] });

The RS256 vs HS256 confusion attack

This one catches people off guard. If your server uses RS256 (asymmetric), it verifies tokens with the public key. An attacker who knows your public key — and it's often published at a JWKS endpoint — can sign a forged token using that public key as an HMAC secret and set "alg": "HS256". If your server doesn't pin the expected algorithm, it verifies the HS256 signature using the public key as the HMAC secret, which the attacker controls.

The fix is the same: hardcode the expected algorithm server-side. Never let the incoming token header influence which algorithm you use for verification.

JWT Attack Simulator — select a scenario and press Play

CLIENTATTACKERSERVER
1
Valid HS256 token
2
Attacker intercepts
3
Strips signature
4
Sends forged token
5
❌ Accepts if misconfigured
Press Play to begin

Press Play to walk through the attack

Your signing secret is probably too weak

"secret", "mysecret", "jwt_secret" — these appear in breach databases more often than you'd think because developers copy them from tutorials and forget to change them. For HS256, the HMAC key should be at least 256 bits of entropy. For HS512, use 512 bits.

A weak secret means your tokens can be brute-forced offline. Once an attacker has one valid token, they can run tools like hashcat or jwt_tool against it without touching your server.

Use a cryptographically random secret with enough entropy — the random secret generator outputs these directly. Store it in an environment variable or secrets manager, never in source code.

Validate every claim you care about

Signing proves integrity. It does not prove the token is usable right now by this user for this purpose. You have to validate claims explicitly.

The minimum you should verify:

  • exp — token hasn't expired
  • iss — issuer matches your expected value
  • aud — audience is your service, not some other service that shares the same signing key
  • sub — subject is a user that still exists and is still active in your database

The aud claim is the most commonly skipped. If you have multiple services sharing a signing key (please don't, but if you do), a token issued for Service A is technically cryptographically valid for Service B if you're not checking audience. That's an authorization bypass.

const payload = jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'https://auth.yourapp.com',
audience: 'api.yourapp.com',
});

// Still check that the user account is active
const user = await db.users.findById(payload.sub);
if (!user || user.deactivated) throw new Error('User not found');

Keep tokens short-lived and use refresh tokens properly

Access tokens should expire in 15 minutes to an hour. Not 24 hours, not 30 days. The reason is that JWTs are stateless — you can't invalidate a single token without either storing a blocklist (which defeats part of the stateless benefit) or waiting for it to expire naturally.

If a token is stolen — via XSS, a log leak, or a compromised third party — a short expiry limits the blast radius. Pair short-lived access tokens with longer-lived refresh tokens stored in an HttpOnly cookie (not localStorage). The refresh token endpoint should rotate the refresh token on each use, which lets you detect token theft: if a reused refresh token comes in, someone is replaying a stolen token and you can invalidate the entire session chain.

Don't put sensitive data in the payload

JWT payloads are base64-encoded, not encrypted. Anyone with the token can decode the payload — no key needed. This isn't a vulnerability by itself, but storing PII, roles with sensitive context, or internal system details in the payload leaks that data to anyone who intercepts or logs the token.

If you need the payload to be confidential, use JWE (JSON Web Encryption) instead of JWS. Otherwise, treat the JWT payload as something that could end up in a browser's network tab, a CDN access log, or an error report.

Watch your key rotation strategy

If you ever rotate your signing secret, you need a grace period where both old and new keys are accepted. Most teams don't think about this until they rotate a key in production and immediately log out every active user. Use key IDs (kid header claim) so your verification logic can select the right key for a given token without trying all of them on every request.

HTTP headers and storage

Store access tokens in memory (JavaScript variable or app state) on the frontend, not localStorage. localStorage is accessible to any JavaScript running on the page, which makes stored tokens an XSS target. HttpOnly cookies are better for refresh tokens — they're not accessible to JavaScript at all, only sent automatically with requests.

The gotcha: if you put your access token in a cookie, you need CSRF protection. If you put it in memory, you need to handle page refreshes (the token is gone — use the refresh token cookie to silently reissue it on load). Neither approach is free.

Generate a proper signing secret for your next JWT implementation using the random secret generator — it outputs cryptographically random values at the bit length you specify, ready to drop into your environment config.

Share this post

Generate a secure JWT signing secret

Free, browser-based — no signup required.

Frequently Asked Questions

Related posts