Verifying JWT Signatures in the Browser With Web Crypto

An API returns 401, and the only clue is a long string starting with eyJ. Pasting it into a decoder shows a payload that looks fine, and that’s where most people stop. But a decoded token only tells you what it claims. It doesn’t tell you whether anyone you trust signed it. When I built ToolOnTap’s JWT decoder, I wanted it to answer both questions, using only what the browser already ships: atob, TextDecoder and Web Crypto.

Three parts, two of them readable by anyone

A JSON Web Token (RFC 7519) is three Base64URL strings joined by dots: header.payload.signature. The header says which algorithm signed it, the payload holds the claims, and the signature covers the exact text header.payload.

Base64URL is ordinary Base64 with - and _ in place of + and /, and with the = padding dropped, so a token can sit in a URL or a header without escaping. That’s all the “encoding” there is. The header {"alg":"HS256","typ":"JWT"} always becomes eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9, which is why so many tokens start with the same characters.

The decoder does a few things before it trusts the input:

  • It strips what people paste along with the token: a “Bearer” prefix, quotes, line breaks.
  • It counts the parts. Five parts means an encrypted token (JWE), whose content can’t be read without the decryption key, and the tool says so instead of showing nonsense.
  • It decodes the bytes with TextDecoder in fatal mode, so broken UTF-8 is an error rather than replacement characters.
  • It parses the JSON with the site’s own parser first, which keeps large numbers exact. JSON.parse would round a 64-bit user ID above 2^53 without telling you.

The claims that decide whether a token works

Most 401s come down to three registered claims, all of them Unix times in seconds:

Claim Meaning Check in the tool
exp Expires at “Valid for another …” or “Expired … ago”
nbf Not valid before “Not valid for another …”
iat Issued at Shown as a date, local time and UTC

The countdown updates every 15 seconds while the page is open. nbf is checked before exp, so a token from a server whose clock runs ahead shows up as “not valid yet” instead of looking fine.

One mistake comes up often enough that the decoder looks for it. JavaScript’s Date.now() returns milliseconds, and putting that straight into exp gives a date tens of thousands of years away. The check is one line:

if (typeof exp === 'number' && exp > 1e11) warnings.push('“exp” looks like milliseconds. JWT times are in seconds, so most libraries will read this as a date thousands of years away.')

A time in seconds won’t pass 1e11 until about the year 5138, so the warning doesn’t fire on real expiry dates.

The decoder also warns when alg is none or missing. An unsigned token is just JSON that anyone could have typed.

Decoding isn’t verifying

This is the point I most wanted the page to make. Anyone can change "role": "editor" to "role": "admin", Base64URL-encode it again and paste the result. It decodes perfectly. Only the signature check notices, and the tests include exactly that case: a valid HS256 token with its payload swapped for { admin: true } must come back as invalid.

Verification needs the right key, and the kind of key depends on the algorithm. The tool reads the algorithm from the header and maps it to Web Crypto’s names:

export function algorithm(alg: string): Algo | null {
  const m = /^(HS|RS|PS|ES)(256|384|512)$/.exec(alg)
  if (!m) return null
  const hash = `SHA-${m[2]}` as Algo['hash']
  const kind = ({ HS: 'hmac', RS: 'rsa', PS: 'pss', ES: 'ec' } as const)[m[1] as 'HS' | 'RS' | 'PS' | 'ES']
  return { kind, hash, curve: kind === 'ec' ? `P-${m[2] === '512' ? '521' : m[2]}` : undefined, saltLength: Number(m[2]) / 8 }
}

Two details in there are easy to get wrong:

  • ES512 uses the P-521 curve, not a “P-512”. The 512 is the hash; the curve is a different number. Ask Web Crypto for P-512 and the key import simply fails.
  • PS algorithms use a salt as long as the hash: 32 bytes for PS256, 48 for PS384, 64 for PS512. That’s what RFC 7518 specifies, and Web Crypto needs the number passed explicitly.

Keys: secrets, PEM and JWK

The tool accepts three kinds of key, and it tells them apart by looking at what you pasted.

A shared secret (HS256, HS384, HS512). The same secret signs and verifies, so it’s imported as a raw HMAC key. Some providers store the secret Base64-encoded, and HMAC over the text of that Base64 gives a completely different result than HMAC over its decoded bytes. That’s what the “The secret is Base64-encoded” box is for. A wrong choice usually shows up as “Invalid signature”, so when a secret you’re sure of fails, try the box the other way.

A PEM public key (RS, PS, ES). The -----BEGIN PUBLIC KEY----- block is Base64 around DER bytes in the SPKI format, which Web Crypto imports directly once the header lines and whitespace are removed. Two common mix-ups get their own messages: a private key (“the private key should never leave your server”) and a certificate, which wraps the public key in more structure than SPKI import accepts.

A JWK. If the text starts with {, it’s parsed as a JSON Web Key and imported as one. This is the form identity providers publish at their JWKS endpoint, so you can copy one key from that list and paste it in.

The ECDSA signature format

ECDSA signatures have two common encodings. OpenSSL and many server libraries produce DER, a variable-length structure. JWS defines ES256 and its siblings differently (RFC 7518, section 3.4): the two numbers r and s, each padded to a fixed length and simply concatenated. An ES256 signature is always 64 bytes.

This was the part I expected to need conversion code for, and it didn’t. Web Crypto’s ECDSA uses the same fixed-length r‖s format, so the decoded signature bytes go to crypto.subtle.verify as they are. The tests confirm it by signing with Web Crypto, checking with a PEM key, and checking that a key from a different key pair says “invalid”.

If you sign tokens on a server yourself, this is worth knowing in the other direction. Node’s crypto.sign returns DER for EC keys by default, and a JWT built from that output won’t verify anywhere. JWT libraries handle it for you; hand-rolled code often doesn’t.

What a valid signature does and doesn’t tell you

“Signature verified” means that the header and payload haven’t changed since someone holding that key signed them. It doesn’t mean the token is still in date, meant for your API, or issued by the party you expect. Your server still has to check exp, iss and aud itself, and to choose the algorithm on its side rather than trusting the token’s header. Otherwise an attacker can pick none, or an algorithm your code didn’t plan for.

Some limits of the tool, plainly:

  • It verifies HS, RS, PS and ES at 256, 384 and 512. For any other algorithm, such as EdDSA, the page still decodes the token and says which algorithms it can check.
  • It can’t decrypt JWE tokens, by design.
  • Live tokens are sensitive. The page doesn’t send them anywhere, but a token is a password with an expiry date, so prefer test tokens and don’t share screenshots.

Try it

Open the JWT decoder and press “Try an example”. It signs a fresh HS256 token in your browser, valid for an hour, and fills in its secret, so “Check signature” answers “Signature verified” straight away. Change one character of the payload and check again. To decode the Base64URL parts by hand, the Base64 encoder and decoder reads URL-safe input too. More developer tools are on the developer tools page.

Tools in this article

  • JWT decoder and verifier

    Decode a JWT to read its header, payload and expiry, and check its signature with a secret or public key. Runs in your browser; the token stays private.

  • Base64 encoder and decoder

    Encode text or files to Base64 and decode Base64 back, with proper UTF-8, URL-safe Base64 and file detection. Free, in your browser, no upload.

More articles