JWT Security: 7 Common Mistakes That Let Attackers In
JWTs are easy to use and easy to misuse. Here are the seven mistakes — from the alg:none bypass to storing tokens in localStorage — that turn your auth into an open door.
A token is only as strong as its verification
A JSON Web Token is just base64url-encoded JSON with a signature. The security lives entirely in how you verify it. Skip or weaken that step and the token is meaningless. Here are the seven mistakes that do exactly that.
1. Accepting alg: none
The infamous one. The JWT header declares its own algorithm, and a naive verifier trusts it:
{ "alg": "none", "typ": "JWT" }
If your library honors none, an attacker forges any payload with no signature at all. Always pin the expected algorithm:
import jwt from "jsonwebtoken";
const payload = jwt.verify(token, secret, { algorithms: ["HS256"] });
Never pass the token's own header value as the trusted algorithm.
2. The RS256 → HS256 confusion attack
If your code uses your public RSA key as the verification key but allows HS256, an attacker signs a token with HMAC using your public key as the secret — which they know — and it verifies.
Fix: pin the algorithm to RS256 (or your asymmetric choice) so an HS256 token is rejected outright.
3. Weak or hardcoded signing secrets
An HS256 token is only as strong as its secret. "secret", "jwt_secret", or a short string is brute-forceable offline.
# Generate a strong secret
openssl rand -base64 48
Use a long, random secret from an env var — never commit it, and rotate it if it ever leaks.
4. Not verifying exp (and other claims)
Decoding is not verifying. jwt.decode() reads the payload without checking the signature or expiry. Always verify(), and check the claims that matter:
const payload = jwt.verify(token, secret, {
algorithms: ["RS256"],
issuer: "https://auth.example",
audience: "https://api.example",
// exp and nbf are checked automatically by verify()
});
Validating iss and aud stops a valid token from one service being replayed against another.
5. Putting secrets in the payload
JWT payloads are not encrypted — anyone can base64url-decode them. Paste a token into jwt.io and you'll see the contents in cleartext. Never put passwords, full PII, or API keys in the claims. Keep only identifiers and coarse authorization data.
6. Storing tokens in localStorage
localStorage is readable by any JavaScript on the page, so a single XSS exfiltrates the token and the attacker has full session access. Prefer an HttpOnly, Secure, SameSite cookie:
Set-Cookie: token=...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900
It's invisible to JavaScript, which neutralizes XSS token theft. (Then mind CSRF — see your cookie/SameSite setup.)
7. No revocation and tokens that live forever
JWTs are stateless, so a token is valid until it expires — you can't "log someone out" of a token you can't see. Mitigate:
- Short-lived access tokens (5–15 minutes).
- Refresh tokens stored server-side that you can revoke.
- A denylist (by
jti) for emergency revocation. - Rotate refresh tokens on use and detect reuse.
// Short access token, longer refresh token
const access = jwt.sign({ sub: userId }, secret, { algorithm: "RS256", expiresIn: "10m" });
const refresh = jwt.sign({ sub: userId, jti }, secret, { algorithm: "RS256", expiresIn: "7d" });
A correct verification, end to end
function authenticate(token: string) {
try {
return jwt.verify(token, publicKey, {
algorithms: ["RS256"], // pin it
issuer: "https://auth.example", // check iss
audience: "https://api.example", // check aud
clockTolerance: 5, // seconds of leeway
});
} catch {
return null; // never trust an unverifiable token
}
}
Checklist
- Algorithm pinned;
nonerejected - No RS256/HS256 confusion (asymmetric pinned)
- Strong, env-stored signing secret
-
verify()(notdecode()), withiss/aud/expchecks - No secrets/PII in the payload
- Tokens in HttpOnly cookies, not localStorage
- Short access tokens + revocable refresh tokens
Scan it with Troja
Troja inspects how your app issues and accepts tokens — flagging alg weaknesses, missing expiry checks, tokens in localStorage, and long-lived sessions — and gives you the exact fix. Scan your auth before someone else tests it for you.
Run the scan this post is about.
Free, no signup. See what's hiding inside your walls in ~30 seconds.
Keep reading
All postsTroja vs. checkvibe: the closest scanner comparison (2026)
checkvibe pioneered security + SEO + AEO scanning with AI fix prompts and a 7-engine matrix. Troja matches it and adds connected deep-stack scans. The honest comparison.
ReadTroja vs. Fixnx: which AI website scanner should you use?
Fixnx runs 100+ AI-powered security, SEO and speed checks with credit-pack pricing. Troja adds AEO, connected deep-stack scans and per-finding AI fixes. Compared.
ReadTroja vs. CyScan.io: recon tool vs. fix-it scanner
CyScan.io is a free attack-surface recon scanner — endpoints, subdomains, fuzzing, screenshots. Troja is a fix-and-ship scanner with AI fixes, AEO and deep-stack scans.
Read