Guide 16 min read Updated

JWT Security: Vulnerabilities & Best Practices

Complete JWT security guide: alg:none attacks, algorithm confusion, weak secrets, kid injection, missing claim validation, token storage, and revocation. Every vulnerability with attack code and the fix.

JWTs appear in virtually every modern web application. A single misconfiguration, whether that is trusting the algorithm from the token header, using a weak signing secret, or skipping audience validation, can give an attacker administrative access to every account in your system.

The risks here are not theoretical. The alg:none vulnerability (CVE-2015-9235) affected major JWT libraries across multiple languages and was actively exploited in the wild. Algorithm confusion attacks appear regularly in bug bounty programs and penetration test reports. Weak HS256 secrets are cracked as a routine finding.

This guide covers every significant class of JWT vulnerability: how each attack works at the code level, and the exact fix for each one.

If you are reviewing an existing JWT implementation right now, check these four things first: your library hardcodes the accepted algorithm (not read from the token header); your HS256 secret is at least 32 bytes of random data (not a password); you validate exp, iss, and aud on every verification call; and access tokens expire in 60 minutes or less. These four controls eliminate the majority of JWT vulnerabilities in production systems.

The signature is the entire security model

A JWT’s security rests on one guarantee: the signature cannot be forged without the signing key. The payload is Base64URL-encoded, not encrypted. Anyone holding a JWT can decode and read every claim by running atob() in a browser console. What they cannot do, if the implementation is correct, is change the payload and produce a matching signature.

Every attack in this guide is an attempt to break that guarantee. Some bypass signature verification entirely. Some trick the verifier into using the wrong key. Some exploit the gap between a cryptographically valid signature and a semantically valid token.

Understanding this model makes the attacks obvious once you see them. Each vulnerability exists because some part of the chain trusted input it should not have trusted.

What is a JWT bearer token?

A bearer token is any token where possession alone is sufficient to use it — whoever holds it can present it, no further proof required. A JWT sent in an Authorization: Bearer <jwt> header is a bearer token: the server does not ask the client to prove it is the rightful owner, it trusts the signature and claims. The name comes from the English word “bearer” — “the person bearing (carrying) this token.”

This is the single most important security property of JWTs in practice, and it is the root of most of the attacks below. Because a JWT is a bearer token:

  • A stolen JWT is a stolen credential. Anyone who intercepts the token — via XSS, a log file, a proxy, a compromised device, a URL parameter — can use it exactly as the rightful user could, until it expires. There is no “device binding” unless you explicitly add it (DPoP, mTLS-bound tokens, or proof-of-possession schemes that go beyond plain JWT).

  • Transport security is mandatory. A JWT sent over plain HTTP can be read off the wire. Always use HTTPS. This is non-negotiable for bearer tokens.

  • Storage is the primary attack surface. Because possession = use, where you keep the token determines how easily an attacker can obtain it. The storage section below walks through the tradeoffs in detail. The short version: prefer HttpOnly cookies over localStorage so XSS cannot exfiltrate the token.

  • Expiry is your only automatic revocation. A bearer token cannot be “logged out” server-side without extra infrastructure (blocklist, version counter, refresh-token rotation). Short exp limits the window after a theft. This is why the revocation strategies section exists.

The JWT bearer token model is the default for OAuth 2.0 access tokens and OpenID Connect. It is the right model for the vast majority of web and API authentication — but it demands you treat the token string as you would treat a password: never log it, never expose it to untrusted code, never transmit it unencrypted.

Vulnerability 1: The alg:none attack

Severity: Critical. CVE-2015-9235.

The JWT specification allows alg: "none" as a valid algorithm value. It signals that the token carries no signature. Several early JWT library implementations treated unsigned tokens as cryptographically valid if the header declared alg: none. An attacker could forge any token without knowing the signing key.

The attack works like this:

  1. Take any legitimate JWT issued by the server.
  2. Decode the header and payload. No key is needed. Base64URL decoding requires nothing.
  3. Modify the payload however you want. Change the role, extend the expiry, swap the subject.
  4. Re-encode the header with "alg": "none".
  5. Concatenate header and payload with a dot, then add a final dot with nothing after it: header.payload.

A vulnerable verifier reads alg: none from the header, skips signature verification entirely, and accepts the token as valid.

// What an attacker constructs
function base64url(obj) {
  return Buffer.from(JSON.stringify(obj))
    .toString("base64")
    .replace(/=/g, "")
    .replace(/\+/g, "-")
    .replace(/\//g, "_");
}

const header = base64url({ alg: "none", typ: "JWT" });
const payload = base64url({
  sub: "user_999",
  role: "admin",      // changed from "user" in the original
  exp: 9999999999,
  iss: "https://auth.example.com"
});

// Empty signature segment -- the trailing dot is intentional
const forgedToken = `${header}.${payload}.`;

Vulnerable libraries accept this token as a valid, verified credential.

How to prevent it:

Hardcode the expected algorithm in your verification call. Never derive the algorithm from the token header.

// WRONG: the token decides how it gets verified
const decoded = jwt.verify(token, secret);

// RIGHT: you decide the algorithm, the token does not
const decoded = jwt.verify(token, secret, { algorithms: ["HS256"] });
# PyJWT

# WRONG: includes "none" in the accepted list
payload = jwt.decode(token, secret, algorithms=["HS256", "none"])

# RIGHT: only the algorithm you actually use
payload = jwt.decode(token, secret, algorithms=["HS256"])

Check your library’s documentation to confirm how to lock the algorithm list. In jsonwebtoken for Node.js, it is the algorithms option. In PyJWT, it is the algorithms parameter. In jjwt for Java, use a typed parser builder. The mechanism differs across libraries, but the principle is the same: you specify what is acceptable and the token does not get a vote.

Vulnerability 2: Algorithm confusion (RS256 to HS256)

Severity: Critical.

This attack exploits any system where the verifier reads alg from the token header instead of enforcing a fixed algorithm. It works specifically well against RS256 systems because of one structural fact: RS256 uses a public key for verification, and public keys are available to attackers by definition.

The attack step by step:

  1. The server uses RS256. It signs tokens with a private RSA key and verifies them with the public RSA key.

  2. The attacker obtains the RSA public key. It might be at /.well-known/jwks.json, in a certificate, in source code, or returned in an API response.

  3. The attacker crafts a token with "alg": "HS256" in the header, containing whatever payload they want.

  4. The attacker signs that token with HMAC-SHA256, using the RSA public key as the HMAC secret.

  5. The vulnerable server reads alg: "HS256" from the header, retrieves its RSA public key, uses it as the HMAC secret for verification, and the signatures match.

  6. The server accepts a completely forged token.

import hmac
import hashlib
import base64
import json

# Public key obtained from /.well-known/jwks.json or similar
public_key = b"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\n-----END PUBLIC KEY-----"

def b64url(data):
    if isinstance(data, dict):
        data = json.dumps(data, separators=(",", ":")).encode()
    return base64.urlsafe_b64encode(data).rstrip(b"=")

header = b64url({"alg": "HS256", "typ": "JWT"})
payload = b64url({"sub": "1", "role": "admin", "exp": 9999999999})

signing_input = header + b"." + payload

# The RSA public key used as the HMAC secret
sig = hmac.new(public_key, signing_input, hashlib.sha256).digest()
signature = base64.urlsafe_b64encode(sig).rstrip(b"=")

forged = (signing_input + b"." + signature).decode()

How to prevent it:

Hardcode the expected algorithm. If you use RS256, only accept RS256. Never fall back to HS256.

// RS256 system
const decoded = jwt.verify(token, publicKey, { algorithms: ["RS256"] });

// HS256 system
const decoded = jwt.verify(token, secret, { algorithms: ["HS256"] });

{ algorithms: ["RS256", "HS256"] } is almost always wrong. Do not include both unless you have a specific, documented reason and have thought through the implications for key handling.

Vulnerability 3: Weak HMAC secret keys

Severity: High.

HS256, HS384, and HS512 sign tokens with a shared secret. That secret is the only barrier between an attacker and token forgery. If the secret is weak, it can be brute-forced offline once an attacker obtains any valid JWT.

No server contact is needed after capturing a single token. The attacker uses hashcat in JWT mode:

# Dictionary attack against an HS256 JWT
hashcat -a 0 -m 16500 captured_token.txt rockyou.txt

# With rules to try variations (capitalization, numbers, leetspeak)
hashcat -a 0 -m 16500 captured_token.txt rockyou.txt -r best64.rule

On a modern GPU, hashcat tests hundreds of millions of candidates per second against HS256. A weak secret falls in seconds to minutes. A 32-byte cryptographically random secret would require longer than the age of the universe with current hardware.

What makes a secret weak:

Secret Problem

secret Single dictionary word

password123 Common password pattern

your-256-bit-secret Literal example from the JWT website

A UUID Only 122 bits of entropy, structured format

Any human-memorable phrase Brute-forceable with GPU clusters

Fewer than 32 bytes of random data Below minimum for HS256

How to generate a proper secret:

# Works on Linux, macOS, and Windows with Git Bash
openssl rand -base64 32

# Example output:
# K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols=
// Node.js
const crypto = require("crypto");
const secret = crypto.randomBytes(32).toString("base64url");
# Python
import secrets
secret = secrets.token_urlsafe(32)

The minimum is 32 bytes for HS256, 48 bytes for HS384, and 64 bytes for HS512. In practice, 32 bytes is sufficient regardless of which HMAC variant you use.

Vulnerability 4: kid parameter injection

Severity: High to Critical.

The kid (key ID) header parameter tells the verifier which signing key to use. It is designed for key rotation: when multiple active signing keys exist, kid tells the verifier which one was used to sign this token.

The JWT specification places no restrictions on the format of kid values. They can be integers, UUIDs, file paths, or any string. If the server uses the kid value to look up a key from a database or filesystem without sanitizing it first, the value becomes an injection vector entirely controlled by the attacker.

SQL injection via kid:

// Vulnerable server code
app.post("/api/resource", async (req, res) => {
  const token = req.headers.authorization.split(" ")[1];
  const header = JSON.parse(
    Buffer.from(token.split(".")[0], "base64url").toString()
  );

  // kid is attacker-controlled from the token header
  const result = await db.query(
    `SELECT public_key FROM signing_keys WHERE id = '${header.kid}'`
  );

  const key = result.rows[0].public_key;
  jwt.verify(token, key, { algorithms: ["RS256"] });
});

An attacker sets kid to a SQL injection string:

' UNION SELECT 'attacker-controlled-secret' --

The query returns attacker-controlled-secret as the key. The attacker signed their token with that same string. Verification passes, and the attacker has full control of the token’s contents.

Path traversal via kid:

// Also vulnerable
const keyPath = `/var/keys/${header.kid}.pem`;
const key = fs.readFileSync(keyPath);

An attacker sets kid to ../../dev/null. On Linux, reading /dev/null returns an empty buffer. HMAC with an empty key produces a predictable, deterministic output that the attacker can compute themselves.

How to prevent it:

Validate kid values against a strict allowlist before any lookup. Never interpolate kid into SQL strings or file paths.

// Allowlist approach -- recommended
const VALID_KEY_IDS = new Set(["key-v1", "key-v2", "key-v3"]);

function getSigningKey(kid) {
  if (!VALID_KEY_IDS.has(kid)) {
    throw new Error(`Unknown key ID: ${kid}`);
  }
  return keyStore.get(kid);
}

If you must use a database lookup, use parameterized queries:

// Parameterized query -- safe
const result = await db.query(
  "SELECT public_key FROM signing_keys WHERE id = $1",
  [header.kid]
);

The kid value goes into the parameter slot, not into the query string. SQL injection becomes impossible regardless of what the kid contains.

Vulnerability 5: Missing or incomplete claim validation

Severity: High.

A valid JWT signature proves the token was created by someone who held the signing key and that the token has not been modified since. It says nothing about whether the token is valid for this specific request, at this specific time, for this specific service. That is what claim validation does. Skipping it creates exploitable gaps even when signatures are perfectly implemented.

Most JWT libraries verify signatures by default. Claim validation is often optional or requires explicit configuration. Check your library. Do not assume.

What happens when you skip each claim:

Skipping exp: Tokens never expire. A token leaked in a log file, a URL parameter, or a compromised device remains valid permanently until the signing key rotates.

Skipping iss: Any token signed by any trusted party works against any service sharing the same signing key. Internal microservices that all share one HMAC secret become collectively vulnerable.

Skipping aud: This is the most commonly skipped check and the most dangerous gap. A token issued for one of your services can be replayed against any other service that trusts the same signing key. This is the confused deputy attack.

The confused deputy attack works like this:

Setup:
  auth.example.com issues tokens for multiple services.
  analytics.example.com: easy signup, low-security.
  payments.example.com: requires elevated role, high-value target.
  Both services trust the same signing key.

Attack:
  1. Attacker signs up on analytics.example.com. No friction.
  2. Attacker receives a valid JWT:
     { sub: "attacker", aud: "analytics.example.com", role: "analyst" }
  3. Attacker presents this token to payments.example.com.
  4. payments.example.com verifies the signature -- valid.
  5. No aud check means payments.example.com accepted a token
     never intended for it.
  6. Attacker is now authenticated at a service they never signed up for.

How to prevent it:

Pass the expected issuer and audience explicitly on every verification call.

// Node.js with jsonwebtoken
const decoded = jwt.verify(token, secret, {
  algorithms: ["HS256"],
  issuer: "https://auth.example.com",
  audience: "https://payments.example.com",
});
# PyJWT
import jwt

payload = jwt.decode(
    token,
    secret,
    algorithms=["HS256"],
    issuer="https://auth.example.com",
    audience="https://payments.example.com",
)
// jjwt
Jwts.parser()
    .requireIssuer("https://auth.example.com")
    .requireAudience("https://payments.example.com")
    .verifyWith(secretKey)
    .build()
    .parseSignedClaims(token);

Vulnerability 6: No expiry or excessive token lifetime

Severity: Medium to High.

A JWT without an exp claim is valid until the signing key is rotated. A JWT with exp set one year in the future is the same thing in practice. If that token appears in a log file, a monitoring service, browser history, or a compromised device, the exposure window is the full remaining lifetime.

Standard access token lifetimes in production systems:

Context Recommended lifetime

High-security API (banking, healthcare) 5 minutes

Standard API access token 15 minutes

Web session access token 15 to 60 minutes

Refresh token 7 to 30 days

Password reset or email verification 10 to 30 minutes

Short access token lifetimes work in practice because they are paired with refresh tokens. The access token expires after 15 minutes. The client presents the refresh token to receive a new access token. The refresh token lives longer but is tracked server-side and can be revoked immediately when needed.

Always include jti (JWT ID) as well. It is a unique identifier for the token, required for blocklist-based revocation, and useful for audit logging.

// Issuing an access token correctly
const accessToken = jwt.sign(
  {
    sub: userId,
    role: user.role,
    jti: crypto.randomUUID(),
  },
  secret,
  {
    algorithm: "HS256",
    expiresIn: "15m",
    issuer: "https://auth.example.com",
    audience: "https://api.example.com",
  }
);

Where to store your JWT

The storage choice determines your attack surface. No option has zero risk. The right choice depends on your threat model.

localStorage and sessionStorage:

Tokens stored here are readable by any JavaScript running on the page. One XSS vulnerability anywhere on your domain, whether in a comment field, a third-party analytics script, or an improperly sanitized user input, can silently read and send every stored token to an attacker’s server. sessionStorage limits exposure to the current tab session but does not help if the XSS runs during that session.

HttpOnly cookies (recommended for web):

Tokens in HttpOnly cookies cannot be accessed from JavaScript. document.cookie does not include them. XSS cannot directly steal them. The attack surface shifts to CSRF: a malicious site can trigger requests that automatically include the cookie. Mitigate this with the SameSite cookie attribute and CSRF tokens for state-changing requests.

Set-Cookie: access_token=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900

SameSite=Lax blocks cross-site requests that change state (POST, PUT, DELETE) while allowing top-level navigations. Use SameSite=Strict if your application never needs tokens sent on cross-origin navigations. Always include Secure so the cookie is only sent over HTTPS.

In-memory (JavaScript variable):

Storing the token in a module-level variable or framework state means it disappears on page reload. XSS running during an active session can still read it, but cannot persist it. This is the most secure approach for web, but requires a silent refresh mechanism: an HttpOnly cookie holding a refresh token that issues a new access token on each page load.

Storage comparison:

Storage XSS risk CSRF risk Survives page reload

localStorage High None Yes

sessionStorage High None No (tab only)

HttpOnly cookie Low Medium (mitigated by SameSite) Yes

Memory Low None No

For native mobile applications, use platform secure storage: Keychain on iOS, EncryptedSharedPreferences or Android Keystore on Android. Never store tokens in plain text files or unencrypted databases.

Token revocation strategies

JWTs are stateless by design. The cost of that statelessness is that you cannot revoke a token the way you revoke a session ID. Solutions exist, but each one reintroduces some form of state. Choose based on your revocation requirements.

Strategy 1: Short expiry

Set access token lifetime to 5 to 15 minutes. Compromised tokens expire quickly. No revocation infrastructure is needed. The tradeoff: you cannot force-revoke a token instantly. A user who is logged out still holds a valid token for up to 15 minutes.

Works for most APIs and microservices. Does not work when you need instant account suspension or forced logout.

Strategy 2: JTI blocklist with Redis

Store the jti of each revoked token in Redis. Set the TTL to the remaining lifetime of that token. On every authenticated request, check the jti against the blocklist before processing.

// On logout or forced revocation
async function revokeToken(decoded) {
  const remaining = decoded.exp - Math.floor(Date.now() / 1000);
  if (remaining > 0) {
    await redis.setex(`blocklist:${decoded.jti}`, remaining, "1");
  }
}

// On every authenticated request
async function verifyAndCheck(token) {
  const decoded = jwt.verify(token, secret, { algorithms: ["HS256"] });

  const isRevoked = await redis.exists(`blocklist:${decoded.jti}`);
  if (isRevoked) {
    throw new Error("Token revoked");
  }

  return decoded;
}

Once a token expires naturally, the Redis key expires too and the blocklist stays small. This approach requires every token to carry a jti claim. Add it at issuance using crypto.randomUUID() in Node.js.

Strategy 3: Token version per user

Store a tokenVersion integer per user in your database. Include the current version in each issued JWT. On verification, compare the token’s version to the stored version. Incrementing the counter instantly invalidates all tokens for that user.

// Invalidate all of a user's tokens
await db.query(
  "UPDATE users SET token_version = token_version + 1 WHERE id = $1",
  [userId]
);

// On every verified request
async function verifyWithVersion(token) {
  const decoded = jwt.verify(token, secret, { algorithms: ["HS256"] });

  const user = await db.getUser(decoded.sub);
  if (decoded.tokenVersion !== user.tokenVersion) {
    throw new Error("Token invalidated");
  }

  return decoded;
}

This requires one database read per request, which eliminates the stateless benefit for that service. It is the right choice when you need bulk revocation: one write invalidates every active token for a user simultaneously.

Strategy 4: Refresh token rotation

Access tokens are short-lived (15 minutes). Refresh tokens are longer-lived (7 to 30 days) and tracked in a database with a revoked flag and a rotation counter.

When the access token expires, the client presents the refresh token and receives a new access token plus a rotated refresh token. The old refresh token is marked consumed. If a consumed refresh token is ever presented again, the server detects a potential replay and revokes the entire token family.

This is the architecture used by Google, Auth0, and most major identity providers. It gives short-lived access tokens (low blast radius on leak), long sessions (good user experience), and instant revocation capability through the refresh token database.

JWT security checklist

Algorithm controls:

  • Verifier hardcodes the accepted algorithm and never reads it from the token header

  • alg: none is not accepted in any capitalization

  • HS256 verifiers do not also accept RS256, and RS256 verifiers do not also accept HS256

Key and secret strength:

  • HS256/384/512 secrets are at least 32 bytes of cryptographically random data from a CSPRNG

  • Secrets are not passwords, UUIDs, or human-readable strings

  • RSA keys are at least 2048 bits; ECDSA uses P-256 (ES256) or stronger

  • Signing keys rotate on a schedule; kid headers are used to support multiple active keys during rotation

kid parameter handling:

  • kid values are validated against a strict allowlist before use

  • Database lookups use parameterized queries, not string interpolation

  • File paths are never constructed from raw kid values

Claim validation on every verification call:

  • exp is checked; expired tokens are rejected

  • iss is validated against the expected issuer

  • aud is validated against the expected audience for this specific service

  • nbf is checked if your system issues tokens with a future activation time

Token lifetime:

  • All access tokens include an exp claim

  • Access token lifetime is 60 minutes or less; 15 minutes or less for sensitive APIs

  • Every token includes jti if revocation capability will be needed

Storage:

  • Access tokens are in HttpOnly, Secure, SameSite cookies (not localStorage)

  • Cookie attributes include at least HttpOnly; Secure; SameSite=Lax

  • If memory storage is used, a silent refresh with an HttpOnly refresh token cookie is in place

Operational:

  • Full JWT strings are never written to logs; log jti, sub, or a hash of the token

  • JWKS responses are cached locally with a TTL; remote keys are not fetched on every request

  • Signing secrets and private keys are stored in a secrets manager and rotated regularly


Continue reading