How to verify JWT signature
Recompute the signature over header.payload and compare it with segment three. For HS256 that is createHmac('sha256', secret); for RS256 it is crypto.verify against the issuer public key. Decoding is not verifying: an edited payload decodes as cleanly as the original, and only the recomputed signature exposes the edit.
Why check this
A decoded payload tells you what a token claims, never who wrote it. Run this check when an endpoint accepts a token it should refuse, before sign-off on any service that is new to the token chain, and after a key rotation. It catches the service that reads the claims with a decode helper and never calls a verify function, so a payload with role changed from tester to admin by hand passes straight through. Step 3 on this page is that exact token, and the output shows the mismatch the service failed to look at.
Prerequisites
- Node 22 or later. The commands use
crypto.createHmac,crypto.timingSafeEqual,crypto.generateKeyPairSync,crypto.signandcrypto.verify. See the Node crypto documentation. - The HS256 secret or the RS256 public key from the issuer. For RS256 the public key is usually published at the issuer's JWKS endpoint.
- A writable directory. Step 4 writes a throwaway key pair to two files.
- RFC 7515 for the JWS signing input, which is the header and payload segments joined by a dot.
Steps
- Step 1.
Recompute the HMAC of the sample token and compare it with the signature it carries.
node -e " const c = require('crypto'); const [h, p, sig] = process.argv[1].split('.'); const want = c.createHmac('sha256', process.argv[2]).update(h + '.' + p).digest('base64url'); const a = Buffer.from(sig), b = Buffer.from(want); console.log('signature in token', sig); console.log('signature computed', want); console.log('match', a.length === b.length && c.timingSafeEqual(a, b)); " eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJoMmNoZWNrLWxvY2FsIiwiaWF0IjoxNzU3NTAwMDAwLCJleHAiOjE3NTc1MDM2MDB9.1LVq5DRuwVY-C2EhoGUUYJA9qmygc_w_4HhMay9pVKg h2check-demo-secretsignature in token 1LVq5DRuwVY-C2EhoGUUYJA9qmygc_w_4HhMay9pVKg signature computed 1LVq5DRuwVY-C2EhoGUUYJA9qmygc_w_4HhMay9pVKg match trueThe signing input is the first two segments joined by a dot, in their encoded form. Re-serialising the JSON before hashing changes the bytes and breaks the comparison.
- Step 2.
Build the token an attacker would build: same header, same signature, one claim changed.
node -e " const [h, p, sig] = process.argv[1].split('.'); const claims = JSON.parse(Buffer.from(p, 'base64url').toString('utf8')); claims.role = 'admin'; console.log(h + '.' + Buffer.from(JSON.stringify(claims)).toString('base64url') + '.' + sig); " eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJoMmNoZWNrLWxvY2FsIiwiaWF0IjoxNzU3NTAwMDAwLCJleHAiOjE3NTc1MDM2MDB9.1LVq5DRuwVY-C2EhoGUUYJA9qmygc_w_4HhMay9pVKgeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiIsImlzcyI6ImgyY2hlY2stbG9jYWwiLCJpYXQiOjE3NTc1MDAwMDAsImV4cCI6MTc1NzUwMzYwMH0.1LVq5DRuwVY-C2EhoGUUYJA9qmygc_w_4HhMay9pVKgNo secret was needed to produce it, and it decodes without a complaint.
- Step 3.
Run the same verification against the edited token.
node -e " const c = require('crypto'); const [h, p, sig] = process.argv[1].split('.'); const want = c.createHmac('sha256', process.argv[2]).update(h + '.' + p).digest('base64url'); const a = Buffer.from(sig), b = Buffer.from(want); console.log('role in payload ', JSON.parse(Buffer.from(p, 'base64url').toString('utf8')).role); console.log('signature in token', sig); console.log('signature computed', want); console.log('match', a.length === b.length && c.timingSafeEqual(a, b)); " eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiIsImlzcyI6ImgyY2hlY2stbG9jYWwiLCJpYXQiOjE3NTc1MDAwMDAsImV4cCI6MTc1NzUwMzYwMH0.1LVq5DRuwVY-C2EhoGUUYJA9qmygc_w_4HhMay9pVKg h2check-demo-secretrole in payload admin signature in token 1LVq5DRuwVY-C2EhoGUUYJA9qmygc_w_4HhMay9pVKg signature computed VQedJ2JiYfvr8Wdfl2Xud8S2kuts7VgIKEItyRLtdu0 match falseThe payload reads
adminand the token is invalid. Send this token to the endpoint under test; anything other than 401 is the finding. - Step 4.
Generate a throwaway RSA key pair for the asymmetric half of the check.
node -e " const { generateKeyPairSync } = require('crypto'); const fs = require('fs'); const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); fs.writeFileSync('jwt-private.pem', privateKey); fs.writeFileSync('jwt-public.pem', publicKey); console.log('jwt-private.pem', privateKey.length, 'bytes'); console.log('jwt-public.pem ', publicKey.length, 'bytes'); "jwt-private.pem 1704 bytes jwt-public.pem 451 bytes - Step 5.
Sign an RS256 token with the private key.
node -e " const c = require('crypto'), fs = require('fs'); const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url'); const h = b64({ alg: 'RS256', typ: 'JWT' }); const p = b64({ sub: '42', role: 'tester', iat: 1757500000, exp: 1757503600 }); const sig = c.sign('sha256', Buffer.from(h + '.' + p), fs.readFileSync('jwt-private.pem')).toString('base64url'); fs.writeFileSync('rs256.token', h + '.' + p + '.' + sig); console.log('signing input', (h + '.' + p).length, 'chars'); console.log('signature ', sig.length, 'chars, written to rs256.token'); "signing input 120 chars signature 342 chars, written to rs256.token342 base64url characters is 256 bytes, the modulus size. An RS256 signature is always that long, while an HS256 one is 43 characters.
- Step 6.
Verify the RS256 token with its own public key and with an unrelated one.
node -e " const c = require('crypto'), fs = require('fs'); const [h, p, sig] = fs.readFileSync('rs256.token', 'utf8').split('.'); const other = c.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } }).publicKey; const input = Buffer.from(h + '.' + p), s = Buffer.from(sig, 'base64url'); console.log('alg in header ', JSON.parse(Buffer.from(h, 'base64url').toString('utf8')).alg); console.log('matching key ', c.verify('sha256', input, fs.readFileSync('jwt-public.pem'), s)); console.log('unrelated key ', c.verify('sha256', input, other, s)); "alg in header RS256 matching key true unrelated key false
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| match true with the real secret | The token is intact and from the holder of the key | Move on to the claims: expiry, issuer, audience. |
| match false | The bytes changed, or the key is wrong | Compare the header segment first. A different kid means a rotated key, not an attack. |
| match false and the endpoint still answers 200 | The service decodes without verifying | Highest severity finding on this page. Any payload is accepted. |
| matching key true, unrelated key false | RS256 is behaving as specified | Confirm the service fetches the public key from a pinned issuer. |
| crypto.verify throws on the PEM | The key is the wrong kind or the wrong format | RS256 needs an SPKI public key, not a certificate and not a JWKS object. |
Common mistakes
What to check next
- How to check JWT algorithm: which algorithm the verifier trusts decides whether this check can be bypassed.
- How to decode JWT: read the header and payload that form the signing input.
- How to check JWT expiration: a valid signature on an expired token is still a rejected token.
- How to test API authentication: send the step 2 token at a live endpoint and record the status code.
- Decode a JWT: inspect the header, including
kid, before picking a key.
FAQ
How to verify a JWT token signature without a library?
Steps 1 and 6 are the whole operation: one createHmac for HS256, one crypto.verify for RS256. A library adds the claim checks and the key lookup, both of which still need the test you just ran.
How to check a JWT signature when I only have the public key?
That is enough for RS256, ES256 and PS256. The public key verifies but cannot sign. For HS256 there is no public half, so verification and forgery need the same secret.
Can I verify a signature in the browser?
Yes, with crypto.subtle.verify and an imported key. Do not paste a production secret into a page to do it, since an HS256 secret in front end code lets anyone mint tokens.
Why does the signature change every time I sign the same payload?
For RS256 with PKCS#1 v1.5 it does not. It changes for PS256 and ES256, which add randomness by design. A differing signature there is expected, and verification still succeeds.
Does a valid signature mean the token is valid?
No. It means the payload is unmodified. Expiry, issuer, audience and the algorithm in the header are separate gates, and each one has been bypassed in production somewhere.
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
- API testing checklist
- All api checks checks
intermediate6 minpublished updated Maks Verny