Back to articles
vulnerabilities
10 min readFebruary 1, 2026

JWT Security Mistakes: What I Keep Seeing in Code Reviews

I review a lot of Node.js auth code. The same five JWT mistakes show up over and over. Here's what they are and what to do instead — with nuance, not just "never use localStorage."

JWT Security Mistakes: What I Keep Seeing in Code Reviews

JWT Security Mistakes: What I Keep Seeing in Code Reviews

I review a lot of authentication code. Probably more than is healthy. And the same JWT mistakes show up over and over — not because developers are careless, but because the JWT ecosystem makes it remarkably easy to do the wrong thing. The libraries are permissive by default, the tutorials skip the nuance, and the "just make it work" pressure of a sprint deadline does the rest.

Here's what I keep finding, why it matters, and what to do instead. Some of these are genuinely dangerous. A couple are more nuanced than the security community usually admits.

Quick Refresher: What a JWT Actually Is

A JWT is a compact, URL-safe token with three base64-encoded parts:

header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjMifQ.abc123
  • Header: The algorithm used to sign the token
  • Payload: Claims (user ID, roles, expiry)
  • Signature: Cryptographic proof that the header and payload haven't been tampered with

The thing nobody tells beginners: The payload is NOT encrypted. It's base64 encoded, which is trivially reversible. Anyone who has the token can read the payload. The signature only guarantees integrity, not confidentiality. I've seen developers put email addresses, internal IDs, and even partial credit card numbers in JWT payloads because they assumed "signed" meant "encrypted."


Mistake #1: Storing Tokens in localStorage

This is the one that generates the most heated arguments online, and honestly, it deserves more nuance than the "never use localStorage" crowd gives it.

javascript
// ❌ VULNERABLE to XSS — any injected script can steal this token
localStorage.setItem('token', jwt);

// ✅ HttpOnly cookies can't be read by JavaScript
res.cookie('token', jwt, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  maxAge: 15 * 60 * 1000
});

The risk with localStorage is real: if your site has any XSS vulnerability (and statistically, most non-trivial apps do at some point), an attacker's injected script can read the token and exfiltrate it. With an HttpOnly cookie, JavaScript literally cannot access the token — the browser sends it automatically with requests.

The "it depends" part: HttpOnly cookies introduce their own complexity — you need CSRF protection (which is why we covered CSRF protection in our auth architecture guide), you need proper

sameSite
configuration, and cookie-based auth is harder to use with mobile apps or third-party API consumers. If you're building a first-party SPA with no XSS vectors (strong CSP, no
dangerouslySetInnerHTML
, no third-party scripts), localStorage with short-lived tokens is a reasonable trade-off. But if you're not confident in your XSS defenses, HttpOnly cookies are the safer default.

Mistake #2: Using Weak Secret Keys

This one is indefensible. There's no "it depends" here.

javascript
// ❌ VULNERABLE — can be brute-forced in seconds
const token = jwt.sign({ userId: '123' }, 'secret');

// ✅ 256-bit minimum, generated randomly
const SECRET = crypto.randomBytes(64).toString('hex');
const token = jwt.sign({ userId: '123' }, SECRET, {
  algorithm: 'HS256',
  expiresIn: '15m'
});

I've seen production apps using

"secret"
,
"password"
,
"jwt_secret"
, and my personal favorite, the company name as the signing key. Tools like
jwt-cracker
can brute-force short HMAC secrets in minutes on commodity hardware. Once an attacker has your secret, they can forge tokens for any user.

Generate your secret once with

crypto.randomBytes(64).toString('hex')
, store it as an environment variable, and never commit it to source control. See our guide on secret management for the full picture on keeping credentials out of your codebase.

Gotcha: If you're using asymmetric algorithms (RS256, ES256), the equivalent mistake is using a weak RSA key. Use 2048-bit RSA minimum, or better yet, switch to ES256 (ECDSA) which gives equivalent security with much shorter keys and faster signing.

Mistake #3: Long Expiry Times

This is a spectrum, not a binary. The right expiry depends on your application's risk profile.

javascript
// ❌ VULNERABLE — 30 day token, if stolen attacker has a month
jwt.sign({ userId }, secret, { expiresIn: '30d' });

// ✅ Short access + refresh token pattern
const accessToken = jwt.sign({ userId }, ACCESS_SECRET, { expiresIn: '15m' });
const refreshToken = jwt.sign({ userId }, REFRESH_SECRET, { expiresIn: '7d' });

Short-lived access tokens (15 minutes) paired with longer-lived refresh tokens is the standard pattern. The access token is what gets sent with every API request. The refresh token is stored more securely (HttpOnly cookie or secure storage) and is only used to get new access tokens.

The trade-off: Shorter expiry means more refresh requests, which adds latency and server load. For a banking app, 5-minute access tokens make sense. For a content app where the risk of session hijacking is lower, 30-minute tokens might be fine. Pick a number that matches your threat model, not a number you copied from a tutorial.

Gotcha: A lot of implementations forget to handle the refresh token rotation properly. When a refresh token is used, the old one should be invalidated. Otherwise, if an attacker steals a refresh token, they can keep generating new access tokens indefinitely, even after the user "logs out." This requires server-side tracking of refresh tokens — a database table or Redis set — which is the part most tutorials skip because it's less fun than the JWT parts.

Mistake #4: Sensitive Data in Payload

Remember: JWT payloads are not encrypted. They're readable by anyone.

javascript
// ❌ NEVER put passwords, credit cards, PII, or internal system details in JWT payload
// ✅ Only include: user ID, role, expiry
jwt.sign({ sub: '123', role: 'user' }, secret);

Keep the payload minimal. A user ID and role are usually all you need. If your API handler needs more user information, look it up from the database using the ID from the token. Yes, this adds a database query. That's okay — it's the right trade-off between security and performance.

I've seen tokens containing full user profiles, permissions arrays with 50+ entries, and in one memorable case, the user's hashed password. Every byte you put in the token increases its size (and therefore the size of every HTTP request), and everything in it is readable by anyone who can intercept or inspect the token.

Mistake #5: Not Validating the Algorithm

This is the one that security researchers love to talk about, and for good reason. The

alg: "none"
attack is elegant in its simplicity.

javascript
// ❌ Vulnerable to alg:none attack — attacker can craft unsigned tokens
jwt.verify(token, secret);

// ✅ Always specify allowed algorithms explicitly
jwt.verify(token, secret, { algorithms: ['HS256'] });

Here's the attack: an attacker takes a valid JWT, changes the header algorithm to

"none"
, removes the signature, and sends it. If your server doesn't explicitly check the algorithm, some JWT libraries will accept the unsigned token as valid. The attacker can now set any user ID or role they want.

Modern versions of the popular

jsonwebtoken
Node.js library have mitigations for this, but explicitly specifying
algorithms
in the verify options costs nothing and protects you even if you switch libraries or if a regression is introduced.

Beyond the Big Five

A few more things I see often enough to mention:

  • Not checking the
    iss
    (issuer) and
    aud
    (audience) claims
    : If you have multiple services, a token issued for Service A shouldn't be accepted by Service B. Validate
    iss
    and
    aud
    on every verify call.
  • No revocation mechanism: JWTs are stateless by design, which means you can't "invalidate" a specific token without server-side state. For logout or password change scenarios, you need a token blocklist (usually in Redis with TTL matching the token expiry).
  • Logging tokens: I've seen JWTs appear in application logs, error tracking services, and even URL query parameters. Treat tokens like passwords — never log them, never put them in URLs.

The Honest Assessment

JWT isn't inherently insecure. It's a well-designed standard that solves a real problem. But the developer experience around it is full of footguns — permissive defaults, complex configuration, and a spec that offers too many options (algorithm negotiation being the prime example). If you get the five things above right, you're ahead of most implementations I've reviewed. Pair that with the access/refresh pattern, HttpOnly cookies for web apps, and proper secret management, and you'll have an auth system you can actually be confident in.

Discussion

0 comments

Share your thoughts

No comments yet. Be the first to share your thoughts!