How to verify a JWT
Verification recomputes the signature with your key and then checks the claims — in that order. Reading claims from a decoded token proves nothing, and two specific mistakes void the whole exercise.
By the Withuse team · Updated
What verification actually computes
For HS256 the operation is a keyed hash over the first two segments, joined by the dot, compared against the third. We ran it against the canonical example token to show the pieces line up:
import crypto from "node:crypto";
const [header, payload, signature] = token.split(".");
const expected = crypto
.createHmac("sha256", secret)
.update(`${header}.${payload}`)
.digest("base64url");
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));With the published example token and the secret your-256-bit-secret, the recomputed value reproduces the published signature exactly. Editing one claim — we changed sub to admin — changes the input string, and the comparison fails. That is the entire guarantee a JWT offers: not secrecy, but detection of tampering.
RS256 and ES256 replace the shared secret with a key pair. The issuer signs with the private key and you verify with the public one, which means you never hold anything that could mint tokens. The shape of the check is unchanged.
Mistake one: trusting the header's alg
The algorithm is named inside the token, so the sender chooses it. A verifier that dispatches on that field can be handed "alg": "none" with an empty third segment and instructed that no check is required. RFC 7519 permits unsecured JWTs, so this is a legal token — the vulnerability lives in the verifier.
The related attack sets alg to HS256 on a service expecting RS256. The verifier then treats the issuer's public key as an HMAC secret, and public keys are not secret. Both attacks fall to the same defence: declare the accepted algorithms in your own configuration and reject everything else.
jwt.verify(token, key, { algorithms: ["RS256"] }) // ✓ pinned
jwt.verify(token, key) // ✗ header decidesMistake two: checking claims before the signature
Every claim lives inside the signed payload. Read one before verifying and you are trusting a value the sender wrote. The order is not stylistic:
- Split the token and confirm it has three segments
- Verify the signature with a pinned algorithm and your key
- Only now read claims —
expnot passed,nbfarrived, allowing a few seconds for clock skew - Check
issis the issuer you expect andaudnames your service, so a token minted for another audience cannot be replayed - Apply your own authorisation using the subject and scopes
Step four is skipped more often than it should be. A token is valid wherever the same key is used, so in a system with several services sharing an issuer, the audience check is what stops one service's token from working against another.
Compare signatures in constant time
A plain === on two strings stops at the first differing byte, so how long it takes reveals how many leading bytes matched. Given enough requests, an attacker reconstructs a valid signature without the key. Use crypto.timingSafeEqual in Node, or the equivalent your language provides — we confirmed Node's is available and works on equal-length buffers.
Every established JWT library already does this. That is one of several reasons to use one rather than assembling the check by hand: they also handle key rotation, JWKS fetching and the algorithm pinning above.
Not in the browser
HMAC verification needs the shared secret, and shipping it to the browser gives it to everyone. Public-key verification avoids that but still proves nothing client-side, because an attacker controls their own browser and can simply skip the check. Verification belongs where the authorisation decision is made.
Decoding in the browser remains perfectly legitimate for display and debugging — which is all our decoder does, and why it says so on every result.
Frequently asked questions
How does JWT signature verification actually work?
For HS256 the server recomputes the signature over the exact string header.payload using its secret and compares the result to the third segment. We demonstrated it end to end: HMAC-SHA256 over the canonical example token's first two segments, keyed with "your-256-bit-secret", reproduces the published signature exactly. Changing a single claim in the payload changes the input string, so the recomputed signature no longer matches — we confirmed that too by editing sub to admin. For RS256 and ES256 the arithmetic differs, using the issuer's public key against a signature made with the private one, but the shape is identical. Note what this does not give you: the payload is still readable by anyone, because a signature protects integrity rather than confidentiality.
Why must I pin the algorithm instead of reading it from the token?
Because the header is part of the token and therefore chosen by whoever sent it. A verifier that switches on the alg field can be handed "none" with an empty signature and told there is nothing to check. The related confusion attack sets alg to HS256 on a system expecting RS256, so the verifier uses the issuer's public key — which is not secret — as an HMAC key that an attacker also has. Both are defeated the same way: state the algorithms you accept in your own configuration, reject anything else, and never let the token influence that decision. Most libraries expose this as an algorithms option on the verify call, and omitting it is what leaves the door open.
What should I check after the signature passes?
The claims, in a fixed order. Verify exp has not passed and nbf has arrived, allowing a few seconds of leeway for clock skew. Check iss matches the issuer you expect and aud names your service, so a token minted for a different audience cannot be replayed against yours. Confirm the subject and any scopes are what your authorisation logic needs. All of these live inside the signed payload, which is exactly why they mean nothing until the signature has been checked — do them in the wrong order and you are trusting attacker-supplied data. The audience check is skipped most often, and it is what stops a token minted for one service in your estate from working against another.
Can I verify a JWT in the browser?
Not meaningfully, and attempting it usually indicates a design problem. HMAC verification requires the shared secret, and shipping that to the browser hands it to every visitor, at which point anyone can mint valid tokens. Public-key verification with RS256 is possible without exposing a secret, but it still proves nothing useful client-side: an attacker controls their own browser and can simply skip the check. Verification belongs wherever the authorisation decision is made, which is the server. In the browser, decoding is legitimate for display and debugging only — this site's decoder does exactly that and nothing more, and says so above every result it produces rather than leaving the distinction implied.
Why should I use a constant-time comparison?
Because an ordinary string comparison returns as soon as it finds a differing byte, so the time it takes leaks how many leading bytes were correct. An attacker who can measure that difference across many requests can reconstruct a valid signature byte by byte without ever knowing the key. Node exposes crypto.timingSafeEqual for this, and equivalents exist in every language's standard library — we confirmed Node's is available and works on equal-length buffers. Any established JWT library already does this internally, which is one of several reasons to use one rather than assembling the check yourself — the others being key rotation, JWKS fetching and the algorithm pinning above, none of which are hard individually but all of which are easy to forget.
References
Inspect a token's header and claims with the JWT decoder, and see JWT expiration for the time claims in detail. More tools at withuse.io/tools.