Every JWT library does the same handful of steps under the hood: decode, reconstruct the signing input, verify. Most developers never see this part, jwt.verify() hides it completely, and that's usually fine. But understanding the raw mechanism makes every confusing verification error make sense instantly, instead of feeling like a black box that just says "invalid signature" and leaves you guessing.
Here's what your library is actually doing.
Step 1: split the token into its three parts
A JWT is three base64url-encoded segments joined by dots, header, payload, signature:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dGVzdC1zaWduYXR1cmU
Splitting on . gives you the header, the payload, and the signature, still encoded at this point.
Step 2: decode the header and payload
Base64url is almost the same as standard base64, with - and _ instead of + and /, and no padding. Decode each of the first two segments and you get plain JSON:
function base64urlDecode(str) {
str = str.replace(/-/g, '+').replace(/_/g, '/');
while (str.length % 4) str += '=';
return Buffer.from(str, 'base64').toString('utf8');
}
const header = JSON.parse(base64urlDecode(headerSegment));
const payload = JSON.parse(base64urlDecode(payloadSegment));
This part is why decoding a JWT never requires a secret or a key, the header and payload were never encrypted, only encoded. Anyone can read them. Verification is a completely separate step, and it's the one that actually matters for trust.
Step 3: reconstruct the exact signing input
This is the step people forget when hand-rolling verification. The signature wasn't computed over the decoded JSON, it was computed over the original, still-encoded header and payload, joined by a dot, exactly as they appeared in the token:
const signingInput = `${headerSegment}.${payloadSegment}`;
Re-serializing the parsed JSON and joining that instead will silently produce a different string if key order or whitespace differs even slightly, and your "signature verification" will fail against a perfectly valid token for reasons that have nothing to do with tampering.
Step 4: verify, and this is where HS256 and RS256 diverge
For HS256, verification means recomputing the HMAC yourself with the shared secret and comparing it to the token's signature:
const crypto = require('crypto');
function verifyHS256(signingInput, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(signingInput)
.digest('base64url');
return expected === signature;
}
For RS256, there's no shared secret to recompute anything with. Instead, you verify the signature using the issuer's public key:
function verifyRS256(signingInput, signature, publicKeyPem) {
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(signingInput);
return verifier.verify(publicKeyPem, signature, 'base64url');
}
That public key doesn't come from nowhere, it's published at the issuer's JWKS endpoint specifically so anyone can fetch it and do exactly this kind of independent verification. I wrote a fuller explanation of how that lookup and key-matching process actually works, including the kid header field that tells you which published key to use, in a separate breakdown of JWKS endpoints.
Why bother doing this by hand at all
Not to replace your JWT library in production, there are real edge cases, algorithm pinning, timing-safe comparison, key rotation handling, that a hand-rolled version above doesn't account for and a mature library does correctly. The value is purely in understanding what "signature verification" actually is mechanically, so when a library throws "invalid signature," you have a real mental model of what specifically could be wrong, a mismatched signing input, the wrong key, a genuinely tampered token, rather than treating it as an opaque failure to just retry until it goes away.
Has anyone here ever had to debug a signature mismatch that turned out to be a re-serialization issue like the one in step 3? That one cost me an embarrassing amount of time the first time I hit it.
Top comments (0)