How to Verify a JWT Signature: HMAC, RSA/ECDSA Public Keys, and JWKS/kid

Decoding a JWT is trivial: split on the dots, Base64URL-decode the first two parts, read the JSON. Anyone can do it, including an attacker, because the header and payload are not encrypted. The only thing between a valid session and a forged one is the third part, the signature, and whether your code actually checks it.

Verification is where the mistakes live. The wrong key, the wrong algorithm, or trusting a header field before the signature is confirmed each gets you a token that decodes cleanly and is fake. This guide covers the verify half: how HS256 differs from RS256 and ES256, how the kid header picks a key out of a JWKS, and why kid (and its nastier cousins jku and x5u) are hints your code chooses to honor, not controls you can trust.

The JWT Decoder verifies HS256, RS256, ES256, and EdDSA in the browser, so you can paste a real token and a real key and watch the check pass or fail while you read.

Symmetric vs asymmetric: same JWT, different key model

Every signed JWT signs the same thing: the ASCII string base64url(header) + "." + base64url(payload). The algorithm in the header decides how that string becomes a signature, and that single choice splits verification in two.

HS256 is symmetric. One shared secret both signs and verifies, so the minting and checking parties hold identical bytes. That works inside one service, or between two you both control, where you can ship a secret without it leaking. It does not scale to third parties: anyone who can verify a token can also forge one, because verifying and signing use the same key.

RS256 and ES256 are asymmetric. The issuer holds a private key and signs with it; everyone else holds the matching public key and can only verify. This is the model that makes federated auth work: an identity provider signs ID tokens with its private key and publishes the public key, and a hundred relying parties verify without ever holding a signing secret. RS256 is RSA with SHA-256 (specifically RSASSA-PKCS1-v1_5). ES256 is ECDSA over the P-256 curve with SHA-256, which produces much smaller signatures for equivalent security. EdDSA signs with Ed25519.

One number trips people up: ES512 is ECDSA over the P-521 curve, not a P-512 that does not exist. If the curve name and the algorithm number disagree by a digit, that is why.

The takeaway: HS256 verification needs the secret. RS256, ES256, and EdDSA need a public key, so the whole problem becomes getting the right public key into your verifier.

Verifying an HS256 token

The shared-secret case is simplest. You have the token and the secret, and verification is: recompute the HMAC over the header-dot-payload string with that secret, then compare it to the token's signature using a constant-time comparison. Match means authentic and untampered; no match means rejected.

To see it, paste a token plus the secret into the JWT Decoder, set the algorithm to HS256, and watch the signature line go green or red. Flip one character of the payload and the check fails immediately, which is the whole point.

The secret has to be strong. HS256 keys are HMAC keys, and a short or dictionary secret is brute-forceable offline once an attacker captures a single token. Generate one with the JWT Secret Generator, which sizes the entropy to the algorithm and gives you hex, base64, and base64url forms. Note that JWT's HMAC is computed over the header.payload string, a different input from webhook signatures, where providers like Stripe and GitHub HMAC the raw request body. For those, the HMAC Signature Verifier handles the webhook formats; do not reach for it to check a JWT, because it signs different bytes.

Verifying an RS256 or ES256 token with a public key

For asymmetric tokens you verify against the issuer's public key. You never need, and should never have, the private key on the verifying side. If a tutorial tells you to put a private key in your API gateway to validate incoming tokens, it is wrong.

The mechanics: take the public key, run the algorithm's verify operation over the header.payload string and the decoded signature, and get a boolean. With RS256 the public key is an RSA key; with ES256 it is a P-256 EC key. The JWT Decoder takes the public key in PEM and tells you whether the signature holds.

Keys travel in two shapes and you will hit both. PEM is the -----BEGIN PUBLIC KEY----- block you paste into config and most libraries. JWK is the JSON object ({"kty":"RSA","n":"...","e":"..."} or an EC variant) that JWKS endpoints serve. They carry the same key; only the encoding differs. When a provider gives you a JWK but your library wants PEM, convert with the JWK to PEM Converter, which auto-detects RSA versus EC and runs locally so the key never leaves your machine. To stand up a test issuer, generate a keypair with the RSA Keypair Generator or the ECDSA Key Generator, then mint signed tokens with the JWT Builder and round-trip them through the decoder.

JWKS and kid: how a verifier picks the right key

A real issuer rotates keys and often runs several at once, so it does not hand you one static PEM. It publishes a JWKS, a JSON Web Key Set, at a URL: an array of public keys, each tagged with a key ID. For OIDC providers you discover that URL from /.well-known/openid-configuration, whose jwks_uri field points at the set.

The kid in a token's header tells your verifier which key in that set to use. The flow: read kid, look up the JWK with the matching kid in the JWKS you fetched, load that key, then verify the signature against it. The JWT + JWKS Verifier does this end to end: give it a token and a JWKS endpoint URL, it pulls the set, matches the kid, and validates the signature. To produce a set for testing, the JWKS Generator emits a JWKS with a fresh keypair, a kid thumbprint, and the matching private key.

Why a key set instead of one key? Rotation without downtime. During a roll, the issuer signs new tokens with the new key while the old key stays in the JWKS until the last token signed with it expires. Both kids resolve, nothing breaks, and the old key drops out later. Cache the JWKS so you are not fetching on every request, but honor cache headers and refetch on an unknown kid so a freshly rotated key gets picked up instead of failing valid tokens.

Why kid is a hint, not a control

The header, kid included, is unprotected. The signature covers header.payload, so the header is signed, but you only learn that after verification. At the moment you read kid to choose a key, nothing in the header has been authenticated, and an attacker controls every byte of it.

So kid is a hint that selects among keys you already trust. It must never decide whether to trust a key. The dangerous pattern is using kid to fetch or locate a key rather than to pick from a pinned set. If kid becomes a filename, a database lookup, or a URL, an attacker writes whatever they want into it:

  • Path traversal: a kid of ../../dev/null or a path to an attacker-controlled file, so your code loads a key the attacker chose.
  • SQL injection: kid dropped unsanitized into a query that fetches the key.
  • jku and x5u: these header fields are key URLs. A verifier that fetches the JWKS from the token's jku, or a cert from x5u, lets the attacker supply the verifying key. Forge a token, host a matching key at your own URL, point jku at it, and the signature checks out. Never follow jku or x5u from a token.

The control is to pin the JWKS source out of band: the issuer URL lives in your config or comes from the OIDC discovery document, never from the token. Use kid only to index into keys fetched from that pinned source, and reject tokens whose kid matches nothing there.

The alg-confusion attack, and pinning the algorithm

The most famous JWT verification failure is algorithm confusion, and it looks like correct code. A server issues and verifies RS256 tokens with its RSA keypair, and its public key is by design public. The attacker takes a token, rewrites the header to alg: HS256, and computes an HMAC over header.payload using the server's RSA public key PEM string as the HMAC secret. A verifier that reads the algorithm from the token header sees HS256, grabs the only key it has, the RSA public key, and uses it as the HMAC secret. It recomputes the same HMAC the attacker did, the bytes match, and the forged token validates. The attacker turned a public key into a signing secret because the verifier let the token choose between symmetric and asymmetric verification.

The alg:none variant is the degenerate case: a token claims no signature at all, and a lax verifier accepts an empty signature.

The fix is one rule: pin the accepted algorithm at the call site. Tell your verifier "this issuer signs RS256, verify RS256, full stop," not "read the header and do what it says." If a token arrives with any other alg, reject it before doing any cryptographic work. Most JWT libraries take an allowed-algorithms parameter precisely for this, and passing it is not optional. When you inspect a suspicious token in the JWT Decoder, the header's alg is the first thing to check against what the issuer actually uses.

A valid signature is necessary, not sufficient

Everything above gets you one fact: the token was signed by a key you trust and has not been tampered with. That is the signature's entire job, and where this guide's scope ends. It is not the same as this token being valid to act on.

A signed token can still be expired, not yet active, issued by the wrong party, or meant for a different audience. Those are claim checks, separate from the signature:

  • exp — expiry. Reject if the current time is past it.
  • nbf — not-before. Reject if the token is not active yet.
  • iss — issuer. Must match the issuer you expect.
  • aud — audience. Must include your service. A token minted for a different API is not yours to honor.

A correct verifier does both: confirm the signature against a pinned key and algorithm, then enforce the claims. If you are working through scopes on an OAuth access token, the OAuth Scope Decoder breaks the scope string into the individual grants so you can check the token permits the action, not just that it is authentic. And when you need to reason about the raw token bytes, the Base64URL Encoder / Decoder handles the URL-safe, unpadded encoding JWT uses for each segment.

Use the tool

Skip the manual work. The companion tool runs this in your browser, with nothing uploaded.

JWT Decoder

Frequently asked questions

What is the difference between decoding and verifying a JWT?

Decoding just Base64URL-decodes the header and payload to read their JSON. It needs no key and proves nothing, because those parts are not encrypted and anyone can read or rewrite them. Verifying recomputes the signature over the header-dot-payload string with a key and confirms it matches the token's signature, proving the token was signed by a trusted key and not altered. Always verify before trusting any claim.

Do I need the private key to verify an RS256 or ES256 token?

No. Asymmetric algorithms verify with the public key only. The issuer signs with the private key, and verifiers use the matching public key. Never place a signing private key on the verifying side. JWKS endpoints publish public keys exclusively, which is why they are safe to fetch over the internet.

What does the kid header field do, and can I trust it?

kid selects which key in a JWKS to verify against by matching the key's ID. It is a hint, not a control: the header is unprotected until the signature verifies, so an attacker can set kid to anything. Use it only to index into a set of keys you already trust from a pinned source, never to fetch or locate a key by path, query, or URL. Reject tokens whose kid matches nothing in your trusted set.

What is the JWT algorithm-confusion attack?

A server that verifies RS256 with an RSA public key is tricked into accepting a token whose header says HS256. The attacker computes an HMAC using the public key's PEM text as the shared secret; a verifier that reads the algorithm from the token uses the public key as an HMAC key, recomputes the same value, and the forgery passes. The fix is to pin the accepted algorithm at the verifier and reject any token with a different alg.

Should my verifier follow the jku or x5u URLs in a token header?

No. jku and x5u are attacker-controllable URLs pointing at a key set or certificate. Following them lets the attacker supply the verifying key, so any forged token signed with their own key validates. Pin the JWKS URL out of band, from your configuration or the issuer's OIDC discovery document, and ignore key URLs carried inside the token.

Is a valid signature enough to accept a JWT?

No. A valid signature proves authenticity and integrity but not that the token should be honored. You still must check exp (not expired), nbf (already active), iss (expected issuer), and aud (your service is the intended audience). Skipping these accepts expired tokens or tokens minted for a different application even though their signatures are genuine.

Related tools