Skip to content

How to verify a JWT signature

· 8 min read

Almost every tool that calls itself a "JWT decoder" does two very different jobs, and the difference matters enormously. Decoding a token reveals what is inside it. Verifying it proves that what is inside can be trusted. You can decode any token in a second with no key at all — which is exactly why decoding proves nothing. This guide covers what verification actually computes, how to do it for each algorithm family, and the two classic attacks that slip past careless implementations.

What the signature actually covers

A JWT is three base64url segments joined by dots: header.payload.signature. The signature is computed over the first two segments in their encoded form, joined by the dot:

signature = Sign(base64url(header) + "." + base64url(payload), key)

That detail catches people out. Verification does not re-encode the JSON — it signs the exact bytes that arrived. If you decode a payload, pretty-print it, re-encode it and try to verify, the signature will fail, because JSON key order and whitespace changed the bytes. This is also why you must never "clean up" a token in transit.

Verifying HS256 (shared secret)

HS256 is an HMAC with SHA-256. It is symmetric: the same secret both creates and checks the signature. Verification means recomputing the HMAC over the signing input and comparing it to the signature that arrived.

The consequence people miss: anyone who can verify an HS256 token can also forge one. There is no "read-only" key. If you hand your signing secret to a third-party service so it can validate tokens, you have handed it the ability to mint tokens for any user in your system. Use HS256 only when the issuer and the verifier are the same trust boundary — one service, or a small set that already share secrets.

The comparison itself should be constant-time. A naive string equality check leaks timing information about how many leading bytes matched, which is enough to forge a signature byte by byte given enough attempts. Every serious library does this for you; if you are hand- rolling verification, this is the part to not hand-roll.

Verifying RS256 and ES256 (public key)

RS256 (RSA with SHA-256) and ES256 (ECDSA on the P-256 curve) are asymmetric. The issuer signs with a private key; anyone can verify with the matching public key. This is what makes them right for distributed systems: your API gateway, five microservices, and a mobile backend can all verify tokens without any of them being able to issue one.

In practice you rarely paste a public key by hand. The issuer publishes a JWKS — a JSON Web Key Set, usually at /.well-known/jwks.json — and the token's kid (key ID) header tells you which key in that set to use. Your library fetches the set, caches it, and picks the key by kid. Key rotation then works without redeploying anything: the issuer publishes the new key alongside the old one, starts signing with the new one, and retires the old one once outstanding tokens expire.

The full verification checklist

Checking the signature is necessary but not sufficient. A complete verification does all of this, in roughly this order:

  • Pin the algorithm. Decide which alg values you accept before reading the token, and reject anything else.
  • Select the key by kid, from a key set you fetched from the issuer over TLS — never from a URL supplied inside the token.
  • Check the signature over the raw signing input, in constant time.
  • Check the time claimsexp and nbf, with a small allowance for clock skew. See is this JWT still valid? for the details.
  • Check iss and aud — that the token came from the issuer you expect, and was meant for your service. A perfectly valid token issued for a different audience is not a valid token for you.

Two attacks that beat careless verification

alg: none

The JWT spec includes an algorithm called none, meaning "unsigned". An attacker takes a real token, edits the payload to say "admin": true, sets the header to { "alg": "none" }, and drops the signature entirely. A library that reads the algorithm out of the token and does what it is told will happily accept it — the token said no signature was required, and no signature was provided.

Algorithm confusion (RS256 → HS256)

Subtler and still common. A service verifies RS256 tokens with a public key, which is by definition public. The attacker changes the header to HS256 and signs the tampered token using that public key as the HMAC secret. If the verifier picks its algorithm from the token's header, it will run HMAC-SHA256 with the public key as the secret — and the signature matches, because the attacker computed it the same way.

Both attacks share one root cause: trusting the token to tell you how to verify itself. The fix is the same for both — the verifier decides the algorithm, not the header. Pass an explicit allow-list to your library and never accept a token whose alg falls outside it.

Verifying without sending the token anywhere

Now the practical problem. To debug a failing signature you want to inspect a real token — and a live JWT is a bearer credential. Whoever holds it can act as that user until it expires. Paste it into an online decoder and it travels to someone else's server, lands in their request logs, and passes through whatever CDN and analytics stack sits in front of them. If you also paste the signing secret so the tool can verify it, you have given away the ability to mint tokens.

"We don't store your tokens" is a promise you cannot audit. The alternative is to verify in your own browser, where there is no server to send anything to. KeepItLocally's JWT decoder and verifier runs entirely client-side using the browser's built-in WebCrypto: HS256/384/512 with a shared secret, and RS256/384/512 or ES256/384 with a PEM public key. It is a static page with no backend, and its Content-Security-Policy blocks requests to third-party origins, so the check is enforced by your browser rather than promised by us. Open DevTools → Network and watch nothing leave the page — or disconnect from the internet and see it keep working.

One caveat worth stating plainly: if a token has already been pasted somewhere you do not control, treat it as compromised and rotate it. Verifying it locally afterwards does not undo the exposure.

Quick reference

  • Decoding proves nothing — only a checked signature makes a claim trustworthy.
  • The signature covers the encoded bytes, not the pretty-printed JSON.
  • HS256 verifiers can forge — use RS256 or ES256 across trust boundaries.
  • Never let the token pick the algorithm — pin an allow-list server-side.
  • A valid signature is not a valid token — still check exp, nbf, iss, and aud.

Related reading: what a JWT is, field by field and choosing between HS256, RS256, and ES256.