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
- Node 22 or later.
jwt-public.pemandrs256.tokenfrom How to verify JWT signature, steps 4 and 5. Both live in the current directory.- RFC 7518 section 3.1 for the list of registered
algvalues, includingnone. - A test account. The forged tokens below name a subject, so run them against a target you own.
Steps
- 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_4HhMay9pVKgheader {"alg":"HS256","typ":"JWT"} alg HS256 kid absentWrite the value down. Everything below asks whether the verifier insists on it or merely reads it.
- Step 2.
Build an
alg: nonetoken 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 trueThe token ends in a dot with nothing after it, and the verifier reports success. Send this exact shape to the endpoint under test.
- 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 trueThis 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.
- 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 acceptedOne 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
What to check next
- How to verify JWT signature: the verification step this page attacks, with the key handling in full.
- How to decode JWT: where the header segment comes from and how to read
kid. - How to check JWT expiration: a forged token sets its own
exp, so expiry testing assumes this check passed. - How to test API authentication: send the step 2 and step 3 tokens at the endpoint and record the status codes.
- Decode a JWT: read
algandkidfrom a token in the browser.
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.
Related on this site
- Checker: jwt decode header and payload, expiry, algorithm
- API security review
- All api checks checks
intermediate6 minpublished updated Maks Verny