How JWT Verification Actually Works

·9 min read·jwtsecurityauth

Decoding a JWT is not the same as verifying it. A walkthrough of signing algorithms, the alg:none trap, key rotation, clock skew, and what your backend should actually check.

JSON Web Tokens are everywhere. They carry sessions across microservices, they pass user identity from a mobile app to an API gateway, and they serve as bearer credentials for every OAuth 2.0 and OpenID Connect flow deployed in the last decade. And yet, a large fraction of the JWT code in production today does not actually verify the tokens it receives. It decodes them — which is not the same thing at all.

Anatomy of a JWT

A JWT is three Base64url-encoded segments separated by dots:header.payload.signature. The header declares the signing algorithm. The payload is a JSON object of claims (who the token is for, who issued it, when it expires). The signature is a cryptographic proof, computed over the header and payload, that some party with access to a secret or a private key produced this exact token.

Because the first two segments are just Base64url, anyone can decode them. You can paste a token into the JWT Decoder and see the claims immediately. This is useful for debugging, but it is not what a server is supposed to do when it receives a token.

Decoding is not verifying

Decoding strips the signature away and trusts the bytes. Verifying recomputes the signature from the header and payload, compares it to the signature on the wire, and only then treats the claims as authoritative. If your code path looks like const claims = JSON.parse(atob(token.split(".")[1]))and then trusts those claims to identify the user, you have a security bug. Anyone with the ability to issue an HTTP request can forge that payload.

Signing algorithms you will actually see

Three algorithm families dominate:

  • HS256 / HS384 / HS512 — HMAC with a shared secret. Both the issuer and the verifier need the same secret. Simple, fast, and fine when the producer and consumer are the same codebase. Dangerous when the secret has to be distributed to many services.
  • RS256 / RS384 / RS512 — RSA signatures. The issuer holds a private key; verifiers hold only the public key. This is the shape used by almost every identity provider (Auth0, Okta, AWS Cognito, Google, Apple). The verifier fetches the public key from a JWKS endpoint and caches it.
  • ES256 / ES384 / ES512 — ECDSA signatures. Same public-key shape as RSA, but shorter keys and smaller signatures. Preferred for newer deployments when size matters.

The alg: none trap

Early JWT libraries supported an alg value of none, meaning the token is unsigned and has an empty signature segment. The intent was for contexts where the transport itself was already authenticated. In practice, this created a notorious vulnerability: an attacker took a real token, flipped the header to { "alg": "none" }, stripped the signature, and a badly-written verifier accepted it.

The lesson is not "turn off alg: none" — it is pin the algorithm. Your verifier should be configured with the exact algorithm it expects. If the token header says something else, reject immediately, before even looking at the signature.

The algorithm confusion attack

A more subtle version of the same problem: an attacker takes a service that expects RS256, obtains the service's public key (usually published openly), and signs a forged token using HMAC-SHA256 with the public key as the HMAC secret. A verifier that looks at the header to decide which algorithm to use will happily verify the token, because the public key is, to the HMAC algorithm, just a byte string.

Mitigation, again: pin the algorithm. Do not pass the algorithm from the token header into your verify function. Pass it in from your own configuration.

Claims you must check

A valid signature proves only that the token was produced by someone with the key. It does not prove the token is still valid, or that it was intended for you. At minimum, verify:

  • exp — expiration time. Reject tokens whose exp is in the past, with a tolerance of a few seconds for clock skew.
  • nbf — "not before" time, if present. Reject tokens whose nbf is in the future.
  • iat — issued-at time. Can be used to reject tokens older than your tolerance window even if exp has not passed.
  • iss — issuer. Must match the issuer you expect. A token issued by a different tenant is not your token.
  • aud — audience. Your service should be in the audience list. Otherwise the token is for someone else, and you should not accept it.

Key rotation and JWKS

Identity providers rotate signing keys periodically. Your verifier needs to keep up. The standard approach is a JWKS (JSON Web Key Set) endpoint that lists the current set of public keys, each identified by a kid. Tokens carry the kid of the key they were signed with, and the verifier picks the matching entry.

Cache the JWKS response aggressively, but be prepared to refresh it on a cache miss. If a token arrives with a kid your cache does not know about, fetch the JWKS once and retry — do not reject outright, or you will have a user-facing outage every time the provider rotates.

Clock skew

Servers disagree about the time by small amounts. A token issued one second before you receive it may, from your perspective, have an iat in the future. Most libraries allow a leeway setting (usually 30–60 seconds). Enable it. Without it, you will get sporadic authentication failures during clock drift and NTP updates.

Putting it together: a correct verify

A correct server-side JWT verification looks roughly like this, in pseudo-code:

  1. Parse the token into header, payload, signature segments.
  2. Select the expected algorithm from your configuration (not from the token header).
  3. Select the key, usually by kid, from your JWKS cache.
  4. Recompute the signature. Compare in constant time.
  5. Decode the payload. Validate iss, aud, exp, nbf with appropriate leeway.
  6. Only now treat the claims as authoritative.

Use a library. Do not roll your own. The subtle bugs above are the reason every major language has a well-maintained JWT library that has already been audited by people who think about these attacks for a living.

When to reach for the JWT Decoder

The JWT Decoder on ToolPad is for inspection, not verification. Drop a token in to see its header, its payload, and its expiration in a readable format. It is the fastest way to answer "what did the identity provider actually put in this token?" when debugging an auth flow. For the actual verify step, use a library in your server code and configure it carefully.