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, "/"))));
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}`),
);
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).
- Browser playground (nothing uploaded): https://didrod205.github.io/jwtlens/
-
CLI for CI / scripts:
npx jwtlens scan "$TOKEN"(orverifywith a key) - MIT, zero runtime deps in the core: https://github.com/didrod205/jwtlens
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)