Skip to content
JWT

JWT Attacks, Explained: When a Token Decides How to Verify Itself

A JSON Web Token is only as trustworthy as the check that verifies it. Here is how alg:none, algorithm confusion, weak secrets and injected key headers turn a signed token into one anyone can mint, and how to pin verification down.

Part 1 of 1From the JWT series

At a glance

CWE-347
Interpreter
JWT verification library and the server's key resolution
Required condition
The token's own header chooses the algorithm or key, or the server skips signature or claim checks.
Potential impact
Forged identities, authentication bypass, privilege escalation and server-side requests through key URLs.
Primary defense
Verify against a pinned algorithm and a server-held key set, then require exp, iss and aud.

Safe practice: use benign proof only, in a disposable local lab or on a system you are explicitly authorized to test.

On this page

A JSON Web Token (JWT) is three base64url-encoded parts joined by dots: a header, a set of claims and a signature. The header names the algorithm, the claims say who the bearer is and what they may do, and the signature is the only part that makes those claims worth believing. Anyone can decode a JWT and anyone can write one. Trust exists only if the server checks the signature against a key it controls.

JWT attacks are rarely bugs in the cryptography. They are failures to use it: accepting an unsigned token, letting the token pick its own algorithm or key, signing with a guessable secret, or never checking expiry and audience. OWASP Top 10:2025 addresses this in A07 Authentication Failures, whose prevention advice explicitly says to validate a JWT's aud and iss claims and scopes.

The mental model: the token describes how to verify itself

The header is attacker-controlled input that happens to sit in front of the signature. Every field in it that influences verification is a place where the attacker, not the server, can make the decision.

Header field What it claims What goes wrong if the server trusts it
alg Which algorithm signed the token none skips verification; HS256 turns a public key into a shared secret
kid Which key to use Path traversal or SQL in a key lookup selects attacker-known key material
jwk The verification key itself The attacker signs with their own key and ships the matching public key
jku, x5u Where to fetch the key The server fetches an attacker URL: key substitution and SSRF

RFC 8725, the JWT Best Current Practices, sends every row back to the application: it pins the algorithms it accepts, uses each key with exactly one algorithm, sanitizes kid before any lookup and follows key URLs only to locations it already trusts. The token is checked against those decisions; it never makes them.

How it happens

Modern libraries refuse most of these combinations by default, so the flaw usually lives in a hand-rolled verifier, an outdated dependency or a wrapper that forwards the header's choice to the library. A hand-rolled Node.js verifier shows all the root causes at once:

const crypto = require('node:crypto');

function verifyToken(token, publicKeyPem) {
  const [h, p, s] = token.split('.');
  const header = JSON.parse(Buffer.from(h, 'base64url'));
  const input = `${h}.${p}`;
  let valid;
  if (header.alg === 'none') {
    valid = true;
  } else if (header.alg === 'HS256') {
    const mac = crypto.createHmac('sha256', publicKeyPem).update(input).digest();
    valid = crypto.timingSafeEqual(mac, Buffer.from(s, 'base64url'));
  } else {
    valid = crypto.verify('sha256', Buffer.from(input), publicKeyPem, Buffer.from(s, 'base64url'));
  }
  if (!valid) throw new Error('invalid token');
  return JSON.parse(Buffer.from(p, 'base64url'));
}

The header selects the algorithm, none is a supported branch, the same RSA public key serves as both a verification key and an HMAC secret, and nothing checks exp, iss or aud. Every one of those is a separate forgery path.

A concrete walk-through

An authorized tester holds a valid token for their own lab account. Its header is {"alg":"RS256","typ":"JWT","kid":"k1"} and its claims are {"sub":"1042","role":"user","exp":1798761600}. First, establish the oracle:

GET /api/me HTTP/1.1
Host: app.example
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImsxIn0.eyJzdWIiOiIxMDQyIiwicm9sZSI6InVzZXIiLCJleHAiOjE3OTg3NjE2MDB9.<signature>

The valid token returns 200 with the tester's profile; the same request with no token returns 401. Any forged token that brings back the 200 profile has been accepted. Each forgery keeps the claims identical, so acceptance proves the flaw without impersonating anyone:

  1. Broken signature. Replace the signature with junk. Acceptance means the server never verifies it at all.
  2. alg:none. Swap the header for {"alg":"none","typ":"JWT"} and drop the signature, keeping the trailing dot. Repeat with None and NONE for filters that compare case-sensitively:
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMDQyIiwicm9sZSI6InVzZXIiLCJleHAiOjE3OTg3NjE2MDB9.
  1. Algorithm confusion. Fetch the public key from /.well-known/jwks.json, export it as PEM text, set alg to HS256 and sign with those PEM bytes as the HMAC secret, trying the common PEM encodings and line endings because the bytes must match exactly. A verifier like the one above checks it with the same bytes and agrees.
  2. Header key injection. Embed a freshly generated public key in a jwk header and sign with its private half, or point kid at /dev/null and sign HS256 with an empty key.
  3. Weak secret. For HS256 tokens, no request is needed. Compute HMAC-SHA256 over the first two parts with candidate secrets such as secret or your-256-bit-secret; a match means the signing key is known.

For jku and x5u, the tester sets the header to a unique URL on callback infrastructure operated for the assessment. An incoming fetch proves that the server resolves keys from token-supplied URLs.

The attacker never broke RSA or HMAC. They only asked the server which rules to verify by, and the server let the token answer.

What an attacker gains

  • Any identity. Change sub to another user's ID and the application treats the bearer as that user.
  • Any privilege. Set role to admin or isAdmin to true when authorization reads claims directly.
  • Sessions that never end. A server that ignores exp honors stolen and logged-out tokens indefinitely.
  • Server-side requests. A jku or x5u the server follows is server-side request forgery inside the authentication path.
  • Injection through kid. A kid used as a file path or in a database lookup inherits path traversal and SQL injection.

The payload is only encoded, never encrypted. Email addresses, internal IDs or permissions placed in claims are readable by anyone who sees the token, which is one more form of information disclosure. Tokens in URLs also leak through logs, browser history and Referer headers.

The fix: pin the algorithm and the key

Use a maintained library, pass it the one algorithm your issuer actually signs with, resolve keys only from a configured key set, and require the registered claims. In Node.js with jose:

import { createRemoteJWKSet, jwtVerify } from 'jose';

const jwks = createRemoteJWKSet(new URL('https://auth.example/.well-known/jwks.json'));

export async function verifyAccessToken(token) {
  const { payload } = await jwtVerify(token, jwks, {
    algorithms: ['RS256'],
    issuer: 'https://auth.example/',
    audience: 'api.example',
    requiredClaims: ['exp', 'sub'],
  });
  return payload;
}

The key set URL comes from configuration, so a token's jku, x5u or jwk header cannot introduce a key, and kid only selects among keys the server already trusts. In Python with PyJWT, installed as pyjwt[crypto]:

import jwt

jwks_client = jwt.PyJWKClient("https://auth.example/.well-known/jwks.json")

def verify_access_token(token: str) -> dict:
    signing_key = jwks_client.get_signing_key_from_jwt(token)
    return jwt.decode(
        token,
        signing_key.key,
        algorithms=["RS256"],
        audience="api.example",
        issuer="https://auth.example/",
        options={"require": ["exp", "iss", "aud", "sub"]},
    )

In ASP.NET Core, the JWT bearer handler reads keys from the issuer's discovery document named by Authority and accepts only the listed algorithms:

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://auth.example/";
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidIssuer = "https://auth.example/",
            ValidAudience = "api.example",
            ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
            RequireSignedTokens = true,
            RequireExpirationTime = true,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromSeconds(30)
        };
    });

For new deployments, ES256 or EdDSA keys and signatures are far smaller than RSA's, but it is the pinned algorithm that prevents confusion. If you must use HS256, generate a secret of at least 256 bits with a CSPRNG, for example openssl rand -base64 32, and keep it in a secret store rather than in source or a front-end bundle. Keep claims minimal and lifetimes short.

Fixes that do not hold

  • Blocking the string none: case variants and other header tricks slip past, and the real gap is that the header chooses at all.
  • Decoding instead of verifying: decode helpers in most libraries skip the signature; they belong in debugging tools, not request handling.
  • Building the allowed list from the header: algorithms: [header.alg] is the vulnerability wearing a configuration option.
  • Prefix-matching jku URLs: startsWith('https://auth.example') also matches https://auth.example.attacker.example. Do not follow token-supplied key URLs.
  • Short expiry alone: a forged token can carry any exp it likes.
  • Hiding data with base64: base64url is encoding, not encryption.

A developer review checklist

  1. Find every place a JWT is decoded, and confirm each one verifies before any claim is read.
  2. Pass an explicit allowlist with the single expected algorithm; reject none.
  3. Load keys from configuration or a pinned JWKS URL, never from jku, x5u, jwk or a free-form kid.
  4. Keep asymmetric verification and HMAC secrets in separate code paths and key stores.
  5. Require and validate exp, iss and aud, with a small clock skew.
  6. Give each HMAC algorithm a random secret at least as long as its hash output, 256 bits for HS256, and rotate it.
  7. Keep tokens out of URLs and keep personal data out of claims.

References

Module: JWT Attacks and JWT Analysis. How it probes: Replays captured bearer, cookie and query-string JWTs with their original GET, POST, PUT or PATCH method and body, changing only the header or signature: a replaced signature, alg:none in four letter-case variants, HS256 keyed with the RSA public key from the JWKS or OpenID jwks_uri, an embedded jwk key, kid values that resolve to /dev/null signed with an empty key and, when an out-of-band channel is configured, jku and x5u URLs pointing at it. HMAC tokens are also cracked offline against common secrets, and passive analysis flags alg:none, HMAC algorithms, empty signatures, expired tokens, key-resolution headers and tokens in URLs. How it confirms: The endpoint must first answer the valid token with a 2xx that differs from the no-token response; a forgery counts only when its status and body closely match the valid-token response and stay clearly apart from the no-token one. A recovered secret must reproduce the captured signature exactly, a jku or x5u finding needs a confirmed out-of-band fetch, and accepted forgeries on GET requests are re-sent with a far-past exp and with role and isAdmin claims to record missing expiry checks and privilege escalation. A finding moves from Possible to Confirmed only after this check. Reported as CWE-347, CWE-345, CWE-918 · ATT&CK T1565, T1190 · SARIF 2.1.0.
How SelfSec proves this one
Module
JWT Attacks and JWT Analysis
How it probes
Replays captured bearer, cookie and query-string JWTs with their original GET, POST, PUT or PATCH method and body, changing only the header or signature: a replaced signature, alg:none in four letter-case variants, HS256 keyed with the RSA public key from the JWKS or OpenID jwks_uri, an embedded jwk key, kid values that resolve to /dev/null signed with an empty key and, when an out-of-band channel is configured, jku and x5u URLs pointing at it. HMAC tokens are also cracked offline against common secrets, and passive analysis flags alg:none, HMAC algorithms, empty signatures, expired tokens, key-resolution headers and tokens in URLs.
How it confirms
The endpoint must first answer the valid token with a 2xx that differs from the no-token response; a forgery counts only when its status and body closely match the valid-token response and stay clearly apart from the no-token one. A recovered secret must reproduce the captured signature exactly, a jku or x5u finding needs a confirmed out-of-band fetch, and accepted forgeries on GET requests are re-sent with a far-past exp and with role and isAdmin claims to record missing expiry checks and privilege escalation.
Reported as CWE-347, CWE-345, CWE-918 · ATT&CK T1565, T1190 · SARIF 2.1.0

A reflected response alone is not enough to promote a JWT finding. The classical engine has to confirm the behavior before it reaches the report. See the detection engine

SelfSec scanner

Find JWT in your own app

Reading about the class is one thing. SelfSec tests for it against an application you are authorized to assess, and only calls it real when independent evidence agrees.

Find it Confirmed, not guessed 21 vulnerability modules, with the classical engine owning the verdict. Prove it Evidence you can re-read Every finding keeps the raw request, the response and the baseline it was measured against.
Join the launch list