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

Steps

  1. 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-secret
    
    signature in token 1LVq5DRuwVY-C2EhoGUUYJA9qmygc_w_4HhMay9pVKg
    signature computed 1LVq5DRuwVY-C2EhoGUUYJA9qmygc_w_4HhMay9pVKg
    match true

    The 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.

  2. 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_4HhMay9pVKg
    
    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiIsImlzcyI6ImgyY2hlY2stbG9jYWwiLCJpYXQiOjE3NTc1MDAwMDAsImV4cCI6MTc1NzUwMzYwMH0.1LVq5DRuwVY-C2EhoGUUYJA9qmygc_w_4HhMay9pVKg

    No secret was needed to produce it, and it decodes without a complaint.

  3. 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-secret
    
    role in payload    admin
    signature in token 1LVq5DRuwVY-C2EhoGUUYJA9qmygc_w_4HhMay9pVKg
    signature computed VQedJ2JiYfvr8Wdfl2Xud8S2kuts7VgIKEItyRLtdu0
    match false

    The payload reads admin and the token is invalid. Send this token to the endpoint under test; anything other than 401 is the finding.

  4. 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
  5. 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.token

    342 base64url characters is 256 bytes, the modulus size. An RS256 signature is always that long, while an HS256 one is 43 characters.

  6. 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

Sign: A hand-edited payload is accepted by the service and rejected by the command above.Cause: The service calls a decode helper where a verify call belongs. Decoding takes no key, so it cannot fail, and the absence of an exception is mistaken for a passed check.
Sign: match is false on a token the issuer says is good.Cause: The signing input was rebuilt from the parsed JSON instead of the original segments. Key order and whitespace change the bytes, so the hash changes while the claims look identical.
Sign: Comparison works with == and the endpoint is slower on nearly correct signatures.Cause: String comparison exits at the first differing character, which leaks the position of the difference. timingSafeEqual in step 1 compares in constant time, and it needs the length check first because it throws on unequal lengths.
Sign: RS256 verification fails for every token after a deploy.Cause: The public key was rotated at the issuer and cached at the verifier. Read the kid claim in the header and compare it with the current JWKS entry before suspecting the signature.

What to check next

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.

intermediate6 minpublished updated Maks Verny