JSON Web Tokens are the standard for stateless authentication in modern web APIs, but the verification logic is a common source of security failures. The same token format that makes JWTs convenient also makes them easy to misuse: a slightly incorrect verification step can let an attacker forge tokens, escalate privileges, or impersonate any user. This article walks through the recurring vulnerabilities and the patterns that prevent them.

The Verification Pipeline

A JWT verifier must perform several checks, in order, before trusting the token:

  1. Split the token into three parts. A JWT has exactly three dot-separated parts; anything else is not a JWT.
  2. Base64URL-decode the header and payload. Reject tokens with malformed encoding.
  3. Validate the header. The alg must be one of the algorithms the verifier accepts. The typ should be JWT (or JOSE for some implementations).
  4. Verify the signature. Recompute the signature using the algorithm declared in the header and the verifier’s secret or public key. Compare to the signature in the token.
  5. Validate the standard claims. exp must be in the future. nbf must be in the past. aud must match the verifier’s expected audience. iss should match the expected issuer.
  6. Return the claims to the application code for authorization decisions.

Each step has a corresponding failure mode. The next sections walk through the common ones.

Pitfall 1: Accepting alg: none

The JWT specification defines an “Unsecured JWT” with alg set to none. The token is not signed; the signature segment is empty. The format exists for very narrow use cases (cross-service trust where the channel is already authenticated).

The vulnerability is that some JWT libraries, by default, accept the none algorithm. The attacker submits a token with {"alg":"none","typ":"JWT"} in the header, an arbitrary payload claiming to be the admin user, and an empty signature. If the verifier accepts it, the attacker has just authenticated as the admin.

This vulnerability has caused real-world breaches. The fix is straightforward: explicitly reject the none algorithm. Every modern JWT library has a configuration option for this. The OWASP JWT cheat sheet recommends implementing the rejection in your own code, not relying on the library default:

// Node.js (jsonwebtoken)
const jwt = require('jsonwebtoken');

function verifyToken(token, secret) {
    const decoded = jwt.verify(token, secret, {
        algorithms: ['HS256'],  // explicitly whitelist, never use the library default
        audience: 'https://api.example.com',
        issuer: 'https://auth.example.com',
    });
    return decoded;
}

Pitfall 2: Algorithm Confusion (RS256 vs HS256)

The most subtle JWT vulnerability is the algorithm confusion attack. The setup:

  1. The verifier is configured to accept RS256 (asymmetric). The issuer signs with a private key; the verifier has the public key.
  2. The verifier code reads the alg field from the token header and uses that algorithm for verification.
  3. The attacker submits a token with alg: HS256 in the header and signs the token using the public key as the HMAC secret.
  4. The verifier, trusting the algorithm declared in the header, uses HS256 to verify the signature. The HMAC is computed with the public key as the secret, which matches the attacker’s signature.
  5. The token is accepted. The attacker has forged a token with arbitrary claims.

The vulnerability is exposed if the verifier uses the algorithm declared in the token header to choose the verification algorithm. The fix is to pin the expected algorithm in the verifier code, never trust the token’s declared algorithm:

// Pin the algorithm; never derive it from the token header
const decoded = jwt.verify(token, publicKey, {
    algorithms: ['RS256'],  // only RS256, regardless of what the token says
    audience: 'https://api.example.com',
    issuer: 'https://auth.example.com',
});

The principle: the token is user-controlled input. The algorithm is part of the token. The verifier must never trust user input to determine the verification algorithm.

Pitfall 3: Missing exp Validation

The exp claim is the token’s expiration time. A token without exp is valid forever. If the token is compromised (stolen from a log, leaked in a Git repository, captured by a malicious extension), the attacker has permanent access.

Every JWT library provides exp validation as a default. The failure mode is when the verifier disables the validation, often for “convenience” during development, and the change makes it to production. The fix:

  • Always issue tokens with exp. The standard validity is 15 minutes to 1 hour for access tokens.
  • Always validate exp in the verifier. Most libraries do this by default; check the documentation.
  • For long-lived sessions, use a refresh token with rotation. The refresh token has a longer validity (days or weeks) but is single-use; each refresh issues a new access token and a new refresh token.

Pitfall 4: Putting Sensitive Data in the Payload

JWT payloads are base64-encoded, not encrypted. Anyone with the token can read the payload by base64-decoding it. This is a common misconception: developers treat JWTs as a secure channel and put sensitive data into the claims, only to discover that the data was never hidden.

Examples of data that should never be in a JWT payload:

  • Passwords or password hashes
  • Credit card numbers
  • Personal identification numbers (SSN, national ID)
  • API keys or secrets
  • Internal system state that should not be exposed to the client

The payload is appropriate for non-sensitive claims: user ID, role, session metadata, expiration time. Data that should not be visible to the token holder should be kept on the server and accessed by user ID.

Pitfall 5: Storing JWTs in localStorage

JWTs are often stored in the browser for client-side access. The two common storage locations are localStorage and cookies. The vulnerabilities differ:

  • localStorage: accessible to any JavaScript on the page. A successful XSS attack reads the token and exfiltrates it. The token is then usable until exp.
  • Cookies (non-HttpOnly): accessible to any JavaScript on the page (same as localStorage) and automatically sent with every request to the cookie’s domain. An XSS attack can read the cookie and make authenticated requests.
  • Cookies (HttpOnly, Secure, SameSite=Strict): not accessible to JavaScript. Automatically sent with requests. The browser sends only on the same site, limiting CSRF risk. The headers prevent the most common JavaScript-based exfiltration.

The safest pattern for browser-based apps is to store the JWT in an HttpOnly cookie and let the browser manage the request transport. The token is never exposed to JavaScript, so XSS attacks cannot read it. The trade-off is that client-side code cannot read the token to inspect the claims; if the application needs to do that, it must call the server.

Pitfall 6: Weak Signature Comparison

Signature comparison must be timing-constant. A naive string comparison (expected === actual) returns false on the first differing byte, and the time difference is observable to an attacker who can submit many verification attempts. By varying the signature and measuring the response time, the attacker can recover the correct signature one byte at a time.

The fix is to use a timing-constant comparison function. Every JWT library provides one:

// Node.js (crypto module)
const crypto = require('crypto');
const expected = Buffer.from(expectedSignature, 'base64');
const actual = Buffer.from(actualSignature, 'base64');
if (crypto.timingSafeEqual(expected, actual)) {
    // signatures match
}

The principle applies to comparing any secret value: HMACs, tokens, password hashes. Never use ==, ===, or strcmp for security-sensitive comparisons.

Pitfall 7: Long-Lived Tokens

A token with a one-year expiration is convenient for the user but dangerous for the system. The longer the validity, the larger the blast radius of a compromise. A token leaked today is still valid in 364 days.

Standard practice:

  • Access tokens — 15 minutes to 1 hour. Short-lived, used for API requests.
  • Refresh tokens — days to weeks. Longer-lived, used to obtain new access tokens. Rotated on each use.
  • ID tokens — similar to access tokens, used for identity verification in OpenID Connect.

The shorter the validity, the less risk per token. The trade-off is more frequent refresh requests, which is the cost of security. The pattern is well-understood and implemented by every major identity provider.

Pitfall 8: Trusting the kid Claim

The JWT header may include a kid (key ID) claim that identifies which key to use for verification. The intended pattern is to support key rotation: the issuer signs with a current key identified by kid, and the verifier uses the same kid to look up the matching public key.

The vulnerability arises when the verifier uses the kid claim to choose a key without validation. An attacker can submit a token with kid: "../../../etc/passwd" or kid: "key-with-known-secret" and convince the verifier to use a different key than expected. The fix is to validate the kid against a whitelist of known keys:

// Only accept kid values that are in the known key set
const allowedKids = Object.keys(knownKeys);
if (!allowedKids.includes(decodedHeader.kid)) {
    throw new Error('Unknown key ID');
}
const verificationKey = knownKeys[decodedHeader.kid];

Pitfall 9: Token Sidejacking

A JWT in transit (over HTTPS, in a header, in a cookie) is encrypted by TLS. But the token is also present in other places: the browser’s localStorage, the server’s logs, the proxy’s logs, the database that records issued tokens. Each of these is a potential exfiltration point.

Mitigations:

  • Bind the token to the client. Include a fingerprint of the client (e.g., a hash of a cookie stored only in the user’s browser) in the JWT claims. The verifier checks that the fingerprint matches the cookie. A stolen token is useless without the fingerprint.
  • Use short-lived tokens. The shorter the validity, the smaller the window for sidejacking.
  • Audit token storage. Make sure tokens are not logged in plaintext. Strip them from error reports. Mask them in monitoring dashboards.

Pitfall 10: Replay Attacks

A captured JWT is valid until exp, regardless of how many times it is used. An attacker who captures a token can replay it indefinitely. Mitigations:

  • Use the jti claim. Include a unique identifier for each token. The verifier maintains a list of used jti values and rejects duplicates.
  • Use one-time tokens for sensitive operations. Password resets, payment authorizations, and similar high-value operations should require a fresh token, not a long-lived access token.
  • Bind tokens to the client. The fingerprint binding (above) prevents replay from a different client.

Defense Checklist

A practical checklist for verifying JWTs safely:

  • Explicitly allowlist the algorithms you accept. Reject none. Pin the algorithm in the verifier code; never trust the token header.
  • Validate exp, nbf, aud, and iss on every request.
  • Use a timing-constant comparison for signature verification.
  • Use short-lived access tokens (15 minutes to 1 hour).
  • Validate kid against a whitelist of known keys.
  • Do not put sensitive data in the payload.
  • Do not store tokens in localStorage. Use HttpOnly cookies where possible.
  • Use a tested JWT library. Writing verification from scratch is a security antipattern.
  • Audit token storage. Strip tokens from logs, error reports, and monitoring dashboards.
  • Plan for revocation. Even short-lived tokens must be revocable for emergency response.

Every item on this list addresses a well-documented vulnerability that has caused real breaches. Following the checklist is not optional; it is the minimum bar for using JWTs in production.

How to Audit a JWT Implementation

If you are responsible for a JWT-based system, the audit questions are the same as the checklist above. The JWT Decoder is the right tool for the inspection portion: paste a token issued by the system and verify the claims match what the application expects. Look for:

  • Missing exp
  • Excessive validity (more than a few hours)
  • Sensitive data in the payload
  • Unexpected alg values
  • Missing aud or iss
  • Same secret used across multiple services (violation of principle of least privilege)

A token that fails any of these checks is a finding to investigate. The fix is usually a small change in the issuer configuration; the impact is closing the vulnerability.

The Bottom Line

JWTs are a powerful authentication primitive, but the verification logic is a perennial source of vulnerabilities. The recurring patterns are well-documented: alg: none acceptance, RS256/HS256 confusion, missing expiration, sensitive data in the payload. The fixes are well-known: pin the algorithm, validate the standard claims, use a tested library, follow the checklist. The JWT Decoder is the right tool for inspecting tokens during debugging; the verification itself belongs in a tested library that follows the security checklist.

Further Reading

  • OWASP JSON Web Token Cheat Sheet — the practical security guide for JWTs, with the algorithm-confusion attack pattern.
  • RFC 8725 — JSON Web Token Best Current Practices, the IETF’s recommendations for safe JWT use.
  • Auth0 JWT Handbook — a comprehensive introduction to JWTs in modern applications.
  • PortSwigger Web Security Academy: JWT attacks — hands-on labs for the common JWT vulnerabilities.

Frequently Asked Questions

Is JWT secure by default? The format is secure; the implementations are not. Most JWT libraries have had security advisories for verification vulnerabilities. The fix is to use a maintained library, follow the security checklist, and pin the algorithm explicitly.

Should I use HS256 or RS256? Use HS256 when the issuer and verifier are the same service (you control both the signing and the verification). Use RS256 (or ES256/EdDSA) when the issuer is different from the verifier (an identity provider that multiple services consume). The asymmetric model is more flexible but more complex.

How do I revoke a JWT before it expires? Three options: maintain a revocation list (the verifier checks the list on each request), use short-lived tokens with refresh tokens (the user logs out by discarding the refresh token), or use a centralized session store that mirrors JWT contents (the verifier checks the session state on each request). All three have trade-offs; the right choice depends on the application’s revocation requirements.

What is the difference between a JWT and a JWE? A JWT is signed (the payload is encoded but not encrypted). A JWE is encrypted (the payload is encrypted and only the intended recipient can read it). JWE is much rarer in practice because most applications keep the payload non-sensitive and rely on HTTPS for confidentiality.