DEV Community

chan
chan

Posted on

You're probably leaking production tokens into jwt.io

You hit a 401. You grab the JWT from a log line, drop it into jwt.io to read
the claims, and move on. I did this for years — until I realized that token was
often a still-valid production credential, and I'd just handed it to a
third-party website.

Even when a decoder swears "it stays in your browser," do you really want to take
that on faith for a prod token? You don't have to. Everything jwt.io does —
decode, inspect, verify the signature — can run entirely on your own machine.

Here's what's worth knowing, and a tiny tool that does it locally.

A JWT is three base64url chunks

header.payload.signature. Decoding is just base64url + JSON.parse — no secret
needed, which is exactly why "decode" should never require uploading anything.

const [h, p] = token.split(".").slice(0, 2).map((s) =>
  JSON.parse(atob(s.replace(/-/g, "+").replace(/_/g, "/"))));
Enter fullscreen mode Exit fullscreen mode

The interesting part is everything around the decode.

The footguns a decoder should flag

alg: none. An "unsecured" JWT has no signature. If your server ever accepts
none, anyone can forge any token. This is the first thing to check, and it's a
one-line lint.

HS↔RS algorithm confusion. A server that verifies with the algorithm named in
the token
(instead of a pinned one) can be tricked: an attacker takes your RSA
public key, signs a token with HS256 using that public key as the HMAC
secret, and your server happily verifies it. The fix is to pin the expected alg —
but a linter can at least warn when a token uses a symmetric alg.

Expiry hygiene. No exp means a leaked token is valid forever. A 30-day access
token means a 30-day blast radius. Missing aud/iss means the token isn't scoped
to your service.

Verifying a signature — in the browser

This is the part people assume needs a server. It doesn't. The Web Crypto API
(crypto.subtle) verifies HS/RS/PS/ES signatures client-side.

One gotcha worth the price of admission: ECDSA (ES256) signatures. JWS encodes
them as raw r || s (IEEE-P1363). Browser WebCrypto's ECDSA wants exactly that
format — but Node's crypto defaults to DER and needs dsaEncoding: 'ieee-p1363'.
Same math, two different serializations, and a classic source of "why won't this
verify" bugs.

const ok = await crypto.subtle.verify(
  { name: "ECDSA", hash: "SHA-256" },
  publicKey,          // imported from your PEM/JWK
  rawSignatureBytes,  // the P1363 bytes straight from the JWT
  new TextEncoder().encode(`${h}.${p}`),
);
Enter fullscreen mode Exit fullscreen mode

No network. No upload. Your key never leaves the page.

The tool

I packaged all of this into jwtlens — decode, a security lint, and offline
verification (HS/RS/PS/ES with your own secret, PEM, or JWK).

It's deliberately small and boring — a linter and a verifier, not a service. The
point is that none of this needs to be a service.

If you maintain an auth service, the most useful takeaway isn't the tool — it's:
pin your expected algorithm, set short exps, and stop pasting live tokens into
websites.
The tool just makes the last one easy.

What would you want a JWT linter to catch that I haven't covered? I'm collecting
rules.

Top comments (0)