News
πŸ›‘οΈ Security Tutorials β€Ί Verify a JWT Signature Without Trusting the Header

Verify a JWT Signature Without Trusting the Header

The classic alg=none and algorithm confusion attacks both come from trusting what the token tells you. Here is how to verify JWTs the right way.

JSON Web Tokens are signed, which sounds secure until you realize that by default many libraries let the token itself tell the verifier which algorithm to check. That is the design flaw behind the two most famous JWT vulnerabilities: the alg=none bypass, and RS256-to-HS256 algorithm confusion. This tutorial shows how both work and what correct verification looks like.


Quick JWT refresher

A JWT has three base64url-encoded parts, separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWxpY2UifQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
β”‚                    header              β”‚      payload      β”‚            signature

The header declares the algorithm:

{ "alg": "HS256", "typ": "JWT" }

That field is the problem. Many libraries read alg from the header and pick their verifier based on it. An attacker who controls the token controls the header.


Attack 1 β€” alg: none

The JWT spec includes alg: "none", meaning "this token is unsigned." A naΓ―ve library reads the header, sees none, and skips verification entirely. The signature part of the token can be empty. Any payload the attacker writes is accepted.

Real-world example of a forged token:

eyJhbGciOiJub25lIn0.eyJ1c2VyIjoiYWRtaW4ifQ.

Decoded: alg: none and user: admin. Empty signature. If the server honors alg: none, the attacker is now admin.

Mitigation: never let alg come from the token. Explicitly pass the expected algorithm into your verifier.


Attack 2 β€” RS256 ↔ HS256 algorithm confusion

RS256 verifies with a public key. HS256 uses the same secret for signing and verification (it is symmetric).

If your server verifies tokens with a publicKey variable and the library picks its algorithm from the token header, an attacker can:

  1. Take the server's public key (often literally public β€” .well-known/jwks.json or a PEM in docs)
  2. Craft a token with alg: HS256
  3. HMAC-sign the token using the public key as the HMAC secret
  4. Send it to the server

The server reads alg: HS256, grabs its publicKey variable as the "secret", and successfully verifies the token β€” because the attacker signed it with that exact value.

Mitigation: same as above. Pin the algorithm. Do not let the header choose.


Step 1 β€” The right way in Node.js (jsonwebtoken)

import jwt from 'jsonwebtoken';
import fs from 'node:fs';

const publicKey = fs.readFileSync('./public.pem');

function verifyToken(token) {
  try {
    const payload = jwt.verify(token, publicKey, {
      algorithms: ['RS256'],      // ← pin the algorithm
      issuer: 'https://auth.example.com',
      audience: 'api.example.com',
      clockTolerance: 30,          // seconds; accounts for clock skew
    });
    return payload;
  } catch (err) {
    // verification failed β€” treat as unauthenticated
    return null;
  }
}

The critical line is algorithms: ['RS256']. That argument is an allowlist β€” the library will reject any token whose header declares anything else, including none. Without this option jsonwebtoken will happily let the header decide, which is exactly the vulnerability.


Step 2 β€” The right way in Python (PyJWT)

import jwt

with open('public.pem', 'rb') as f:
    public_key = f.read()

def verify_token(token: str) -> dict | None:
    try:
        return jwt.decode(
            token,
            public_key,
            algorithms=['RS256'],      # pin algorithm
            issuer='https://auth.example.com',
            audience='api.example.com',
            leeway=30,                 # clock skew, seconds
        )
    except jwt.InvalidTokenError:
        return None

PyJWT requires the algorithms argument β€” it throws if you omit it. That is good API design and has probably prevented a lot of production incidents.


Step 3 β€” What to verify beyond the signature

A valid signature means the token has not been tampered with since the issuer signed it. It does not mean the token is meant for you, still valid, or from whom you think it is. Check these claims every time:

  • iss (issuer) β€” must match your trusted auth server
  • aud (audience) β€” must match your service
  • exp (expiration) β€” must be in the future
  • nbf (not before) β€” must be in the past (if present)
  • iat (issued at) β€” sanity-check it is not suspiciously old or in the future

All the libraries above will check exp/nbf/iat automatically if the claims are present. iss and aud must be checked explicitly, as shown.


Step 4 β€” For JWKS-based setups, pin the key ID

If your identity provider rotates keys and publishes them via JWKS (.well-known/jwks.json), the token header carries a kid (key ID) so the verifier knows which key to use. That lookup is fine, but:

  • Fetch the JWKS only from a hardcoded URL β€” never one derived from the token
  • Cache the JWKS; do not re-fetch per request, or your auth server becomes a DDoS target
  • Reject tokens whose kid is not in the JWKS, rather than refreshing the JWKS on every unknown kid (an attacker could flood you with random kid values to force cache misses)

A small Node example:

import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(
  new URL('https://auth.example.com/.well-known/jwks.json'),
  { cooldownDuration: 60_000 }   // cap re-fetch rate
);

export async function verify(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'https://auth.example.com',
    audience: 'api.example.com',
    algorithms: ['RS256'],
  });
  return payload;
}

The jose library in particular has a solid track record of refusing to do unsafe things by default, which is why I reach for it in new projects.


Checklist for any JWT verifier in your codebase

Go through your code and confirm:

  • The algorithm is pinned to an allowlist at the verify call site
  • iss is verified against a hardcoded value
  • aud is verified against a hardcoded value
  • exp is checked (usually automatic, but worth confirming)
  • The key material source is hardcoded, not derived from the token
  • No code path calls decode when it should be calling verify
  • Any inspection of the token payload before verification is clearly marked as untrusted

That last one trips people up. jwt.decode (without verification) is legitimate for looking up which key to fetch, but the payload from decode is attacker-controlled until verify returns cleanly. Treat it accordingly.


Inspecting suspicious tokens

If you are reviewing a log and want to decode a JWT to see what was inside β€” without actually trusting it β€” paste it into SysEmperor's JWT Decoder. The tool runs entirely in the browser, so the token never leaves your device, which matters when the token in question may contain session info you would rather not post to a random online decoder.