Blog
jwtauthenticationsecurityvulnerabilitiestokens

JWT Security: 7 Common Mistakes That Let Attackers In

CheckVibe Team
15 min read

JWTs are everywhere — and so are the vulnerabilities. An estimated 82% of web applications use JSON Web Tokens for authentication, from single-page apps to microservice architectures. They're elegant, stateless, and easy to implement.

They're also easy to implement wrong.

A single JWT misconfiguration can give an attacker full access to every account in your system. No SQL injection required. No XSS needed. Just a malformed token and a server that trusts it.

This guide covers the 7 most dangerous JWT security vulnerabilities we see in real-world applications, with vulnerable code and fixed code for each one. If you're shipping JWTs in production, you need to check every item on this list.

How JWTs Work (Quick Refresher)

A JSON Web Token has three parts, separated by dots:

header.payload.signature
  • Header — declares the token type and signing algorithm (e.g., HS256, RS256)
  • Payload — contains claims like user ID, role, and expiration time
  • Signature — cryptographic proof that the token hasn't been tampered with

The server creates a JWT by signing the header and payload with a secret key. When the token comes back in a request, the server verifies the signature before trusting the claims inside.

// Creating a JWT
import jwt from 'jsonwebtoken';

const token = jwt.sign(
  { userId: '123', role: 'user' },
  process.env.JWT_SECRET,
  { algorithm: 'HS256', expiresIn: '1h' }
);

// Token looks like: eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIxMjMifQ.SflKxwRJ...

The key property of JWTs is that they're stateless. The server doesn't need to check a database to validate the token — the signature alone proves authenticity. This is what makes them fast and scalable.

It's also what makes them dangerous when implemented incorrectly. There is no server-side session to revoke. If a token is valid, it's valid — period. That makes every mistake in this list significantly more impactful than it would be with traditional session-based authentication.

Mistake #1: Using decode() Instead of verify()

This is the number one JWT security mistake beginners make, and we see it in production applications regularly.

The jsonwebtoken library exposes two functions: jwt.decode() and jwt.verify(). They look similar, but one is secure and the other is catastrophically not.

The Vulnerable Code

// VULNERABLE: decode() does NOT verify the signature
import jwt from 'jsonwebtoken';

export async function GET(req: NextRequest) {
  const token = req.headers.get('authorization')?.replace('Bearer ', '');
  const payload = jwt.decode(token);

  // Attacker can forge any payload — this trusts it blindly
  const user = await db.users.findById(payload.userId);
  return NextResponse.json(user);
}

jwt.decode() is a base64 decoder. That's it. It parses the payload and returns whatever is inside. It does not check the signature. An attacker can craft a token with any claims they want — { userId: 'admin', role: 'superadmin' } — and your server will trust it.

The Fix

// SECURE: verify() checks the signature before returning claims
import jwt from 'jsonwebtoken';

export async function GET(req: NextRequest) {
  const token = req.headers.get('authorization')?.replace('Bearer ', '');

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'], // Whitelist allowed algorithms
    });

    const user = await db.users.findById(payload.userId);
    return NextResponse.json(user);
  } catch (err) {
    return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
  }
}

Always use jwt.verify(). The only legitimate use for jwt.decode() is when you need to inspect a token's claims before verification — for example, to determine which key to use in a multi-tenant system. Even then, never trust the decoded values for authorization.

Mistake #2: Accepting the "none" Algorithm

The JWT specification includes an alg: "none" option, which means "this token is unsigned." It was intended for cases where the token has already been verified through some other mechanism. In practice, it's an attacker's dream.

The Attack

An attacker takes a valid token, modifies the header to set alg: "none", changes the payload to whatever they want, removes the signature, and sends it to your server.

// What the attacker sends:
// Header: { "alg": "none", "typ": "JWT" }
// Payload: { "userId": "admin", "role": "superadmin" }
// Signature: (empty)

// Encoded: eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VySWQiOiJhZG1pbiIsInJvbGUiOiJzdXBlcmFkbWluIn0.

If your server doesn't explicitly reject alg: "none", this unsigned token will be accepted as valid. The attacker now has admin access.

The Vulnerable Code

// VULNERABLE: No algorithm restriction
const payload = jwt.verify(token, process.env.JWT_SECRET);
// Some libraries accept "none" by default

The Fix

// SECURE: Explicitly whitelist allowed algorithms
const payload = jwt.verify(token, process.env.JWT_SECRET, {
  algorithms: ['HS256'], // ONLY accept HS256 — reject everything else
});

The algorithms option is your first line of defense. Always specify exactly which algorithms your server accepts. Modern versions of the jsonwebtoken npm package reject none by default, but older versions and other libraries may not. Don't rely on defaults — be explicit.

Mistake #3: Algorithm Confusion (RS256 to HS256)

This is the most sophisticated JWT vulnerability on this list, and it has affected major platforms including Auth0 and Microsoft Azure. It exploits a fundamental misunderstanding in how asymmetric vs. symmetric signing works.

How It Works

When your server uses RS256 (asymmetric), it signs tokens with a private key and verifies them with a public key. The public key is often available in a JWKS endpoint or embedded in the application.

The attack: the attacker takes your public key, creates a new token, signs it with that public key using HS256 (symmetric), and sets alg: "HS256" in the header.

If your server sees alg: "HS256" and uses its configured "key" (the public key) as the HMAC secret, the signature will verify. The attacker has forged a valid token using only your public key.

// ATTACK SCENARIO

// 1. Attacker obtains the public key (it's public, after all)
const publicKey = await fetch('https://yourapp.com/.well-known/jwks.json')
  .then(res => res.json());

// 2. Attacker signs a forged token using the public key as an HMAC secret
const forgedToken = jwt.sign(
  { userId: 'admin', role: 'superadmin' },
  publicKey,  // Public key used as HMAC secret
  { algorithm: 'HS256' }
);

// 3. Server sees alg: "HS256", uses its "key" (the public key) to verify
//    Signature matches. Attacker is now admin.

The Vulnerable Code

// VULNERABLE: Algorithm determined by the token header
const payload = jwt.verify(token, publicKey);
// If token says HS256, server uses publicKey as HMAC secret — forged token passes

The Fix

// SECURE: Enforce the expected algorithm
const payload = jwt.verify(token, publicKey, {
  algorithms: ['RS256'], // ONLY accept RS256 — reject HS256 entirely
});

The fix is the same algorithms whitelist from Mistake #2. By explicitly specifying ['RS256'], the server will reject any token claiming to use HS256, regardless of whether the signature happens to verify. Never let the token's own header dictate which algorithm your server uses.

Mistake #4: Weak or Default Secrets

You'd be surprised how many production applications sign JWTs with "secret", "password123", or the example string from a tutorial. These are crackable in seconds.

The Attack

JWT secrets can be brute-forced offline. An attacker with a single valid token can try billions of potential secrets without touching your server.

# hashcat cracks weak JWT secrets in seconds
hashcat -a 0 -m 16500 jwt.txt wordlist.txt

# Common weak secrets that get cracked instantly:
# "secret", "password", "123456", "your-256-bit-secret"
# "jwt_secret", "mysecretkey", "change-me"

Tools like jwt-cracker and hashcat with JWT mode can test millions of candidate secrets per second. If your secret is under 32 characters or based on a dictionary word, it will be found.

The Vulnerable Code

// VULNERABLE: Weak secret from a tutorial
const token = jwt.sign(payload, 'your-256-bit-secret', { algorithm: 'HS256' });

// VULNERABLE: Short, guessable secret
const token = jwt.sign(payload, 'mysecret', { algorithm: 'HS256' });

The Fix

// SECURE: Generate a cryptographically random secret
// Run once: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
// Store in environment variable, never in code

const token = jwt.sign(payload, process.env.JWT_SECRET, {
  algorithm: 'HS256',
  expiresIn: '1h',
});

// JWT_SECRET should be at least 256 bits (64 hex characters) of random data
// Example: JWT_SECRET=a3f2b8c9d4e5...64 random hex chars

For HMAC algorithms (HS256, HS384, HS512), your secret must be at least as long as the hash output: 32 bytes for HS256, 48 for HS384, 64 for HS512. Use crypto.randomBytes() to generate it. Better yet, use asymmetric algorithms (RS256 or ES256) where there is no shared secret to crack.

Mistake #5: Missing Expiration (No exp Claim)

A JWT without an expiration date is a permanent access key. If it's ever stolen — through an XSS attack, a compromised log file, a leaked database backup, or a careless screenshot — the attacker has permanent access to that account.

The Vulnerable Code

// VULNERABLE: Token never expires
const token = jwt.sign({ userId: '123', role: 'admin' }, secret);
// This token is valid forever. If stolen, it grants permanent access.

The Fix

// SECURE: Short-lived access token + refresh token pattern
const accessToken = jwt.sign(
  { userId: '123', role: 'admin' },
  secret,
  {
    algorithm: 'HS256',
    expiresIn: '15m',  // Short-lived: 15 minutes
  }
);

const refreshToken = jwt.sign(
  { userId: '123', tokenType: 'refresh' },
  secret,
  {
    algorithm: 'HS256',
    expiresIn: '7d',   // Longer-lived, stored securely
  }
);

// On verification, expiration is checked automatically
try {
  const payload = jwt.verify(token, secret, { algorithms: ['HS256'] });
} catch (err) {
  if (err.name === 'TokenExpiredError') {
    // Token expired — client should use refresh token to get a new one
    return NextResponse.json({ error: 'Token expired' }, { status: 401 });
  }
}

Set access tokens to expire in 15 minutes or less. Use refresh tokens (stored in httpOnly cookies) for longer sessions. This limits the damage window if a token is stolen. An attacker with a 15-minute token has 15 minutes. An attacker with a forever-token has forever.

Also validate the iat (issued at) and nbf (not before) claims. Tokens issued before a known compromise date should be rejected.

Mistake #6: Storing Sensitive Data in the Payload

JWTs are encoded, not encrypted. The payload is a base64url string that anyone can decode. It takes one line of code:

// Anyone can read JWT payloads — no key needed
const payload = JSON.parse(
  Buffer.from(token.split('.')[1], 'base64url').toString()
);

If you're storing sensitive data in the payload, every proxy server, browser extension, log aggregator, and network observer along the path can read it.

The Vulnerable Code

// VULNERABLE: Sensitive data in the payload
const token = jwt.sign({
  userId: '123',
  email: 'user@example.com',
  ssn: '123-45-6789',           // Social security number in plaintext!
  creditCard: '4111111111111111', // Credit card in plaintext!
  internalRole: 'billing_admin',  // Internal role leaks system architecture
  dbConnectionString: 'postgres://...',  // Catastrophic leak
}, secret);

The Fix

// SECURE: Minimal claims — only what's needed for authorization
const token = jwt.sign({
  sub: '123',    // User ID (standard claim)
  role: 'user',  // Authorization role
  iat: Math.floor(Date.now() / 1000),
  exp: Math.floor(Date.now() / 1000) + (15 * 60),
}, secret, { algorithm: 'HS256' });

// Look up sensitive data server-side using the user ID
// Never put PII, secrets, or internal data in the token

Only include the minimum claims needed for authorization: a user identifier, a role, and the standard timing claims (iat, exp, nbf). Everything else should be fetched server-side using the user ID from the token. If you absolutely need encrypted claims, use JWE (JSON Web Encryption) instead of JWS (JSON Web Signature).

Mistake #7: Not Invalidating Tokens on Password Change or Logout

JWTs are stateless by design. The server doesn't track active sessions. This means that when a user changes their password (because they suspect their account is compromised) or logs out, all previously issued tokens remain valid until they expire.

If the attacker already has a token, changing your password does nothing.

The Vulnerable Code

// VULNERABLE: Password change doesn't invalidate existing tokens
export async function POST(req: NextRequest) {
  const { currentPassword, newPassword } = await req.json();

  await db.users.updatePassword(user.id, newPassword);

  return NextResponse.json({ message: 'Password updated' });
  // All old tokens still work! Attacker keeps access.
}

The Fix

// SECURE: Track a token version per user, invalidate on password change
// Database: users table has a `tokenVersion` integer column

// When signing tokens, include the token version
const token = jwt.sign({
  sub: user.id,
  role: user.role,
  tokenVersion: user.tokenVersion,  // Current version from DB
}, secret, { algorithm: 'HS256', expiresIn: '15m' });

// When verifying, check the token version matches
async function verifyToken(token: string) {
  const payload = jwt.verify(token, secret, { algorithms: ['HS256'] });
  const user = await db.users.findById(payload.sub);

  if (payload.tokenVersion !== user.tokenVersion) {
    throw new Error('Token revoked');  // Old token — reject it
  }

  return payload;
}

// On password change or logout, increment the version
export async function POST(req: NextRequest) {
  const { currentPassword, newPassword } = await req.json();

  await db.users.updatePassword(user.id, newPassword);
  await db.users.increment(user.id, 'tokenVersion');  // Invalidates ALL existing tokens

  // Issue a new token with the updated version
  const newToken = jwt.sign({
    sub: user.id,
    role: user.role,
    tokenVersion: user.tokenVersion + 1,
  }, secret, { algorithm: 'HS256', expiresIn: '15m' });

  return NextResponse.json({ token: newToken });
}

Yes, this adds a database lookup on every request, which partly defeats the "stateless" benefit of JWTs. That's the tradeoff. For most applications, the security benefit far outweighs the performance cost. Alternatives include a short token expiration (making the revocation window small) or a server-side blocklist of revoked token IDs (jti claim) cached in Redis.

JWT Security Best Practices Summary

Here's a quick-reference checklist:

  • Always use verify(), never decode() for authorization decisions
  • Whitelist algorithms explicitly — set algorithms: ['RS256'] or algorithms: ['HS256']
  • Use RS256 or ES256 over HS256 when possible — asymmetric algorithms eliminate shared secret risks
  • Generate secrets with crypto.randomBytes(64) — minimum 256 bits of entropy
  • Set short expiration times — 15 minutes for access tokens, 7 days for refresh tokens
  • Keep payloads minimal — user ID, role, and timing claims only
  • Implement token revocation — version counter, blocklist, or short expiration with refresh rotation
  • Store tokens in httpOnly, Secure, SameSite cookies — not localStorage
  • Rotate signing keys periodically — use a JWKS endpoint with key IDs (kid) for zero-downtime rotation
  • Validate all standard claimsexp, iat, nbf, iss, aud

How CheckVibe Detects JWT Issues

CheckVibe's JWT audit scanner automatically checks your application for common JWT security vulnerabilities:

  • Weak algorithm detection — flags tokens using none, HS256 with short secrets, or deprecated algorithms
  • Missing expiration — identifies tokens without exp claims or with excessively long lifetimes
  • Exposed tokens in client-side code — scans JavaScript bundles and HTML for hardcoded JWT strings
  • Algorithm confusion risk — detects JWKS endpoints that could be exploited for RS256/HS256 confusion attacks
  • Insecure token storage — flags tokens stored in localStorage or passed via URL query parameters

These checks run as part of every scan alongside 36 other security checks covering security headers, CORS misconfiguration, XSS vulnerabilities, and more.

Run a free security scan to see if your application has JWT vulnerabilities.

Frequently Asked Questions

Should I use JWTs or sessions?

It depends on your architecture. Server-side sessions are simpler and inherently support revocation — ending a session is just deleting a row. JWTs work better for distributed systems, microservices, and APIs where you need stateless authentication across multiple services. For a standard web application with a single backend, sessions are often the safer default. For SPAs calling APIs, JWTs with short expiration and refresh token rotation are the standard approach.

How long should a JWT last?

Access tokens should last 15 minutes or less. Refresh tokens can last 7-30 days depending on your security requirements. For high-security applications (banking, healthcare), consider 5-minute access tokens with sliding refresh. The shorter the lifetime, the smaller the window an attacker has with a stolen token.

Can JWTs be hacked?

JWTs themselves are cryptographically sound when implemented correctly. The vulnerabilities are in how developers use them: weak secrets, missing algorithm checks, no expiration, trusting decoded payloads. A properly configured JWT with a strong secret, whitelisted algorithms, short expiration, and proper verification is extremely difficult to forge.

What is the safest JWT algorithm?

ES256 (ECDSA with P-256 curve) is the current best choice. It's asymmetric (no shared secret), has smaller key sizes than RSA, and is faster to verify. RS256 is also solid and more widely supported. Avoid HS256 in systems where the secret must be shared across multiple services — if any one service is compromised, all tokens can be forged.

What is algorithm confusion in JWT?

Algorithm confusion (also called algorithm substitution) is an attack where the attacker changes the alg field in a JWT header from an asymmetric algorithm (like RS256) to a symmetric one (like HS256). If the server uses the same key material for both — which happens when the public key is used as the HMAC secret — the attacker can forge tokens using only the publicly available key. The fix is to always whitelist the expected algorithm in your verification code.


JWT security vulnerabilities are among the most common authentication flaws in modern web applications. The good news is that every vulnerability on this list has a straightforward fix. Whitelist your algorithms, use strong secrets, set short expirations, and always verify before you trust.

Want to know if your app is vulnerable? CheckVibe scans for JWT issues alongside 36 other security checks — including everything covered in our API security checklist and our guide to securing vibe-coded apps.

Is your app vulnerable?

Paste your URL and get a security report in 30 seconds. 100+ automated checks, AI-powered fix prompts for Cursor & Copilot.

Scan Your App Free