Building ToolRunner·8 min read

Why Your JWT Should Never Leave the Browser

Pasting a JWT into a server-backed decoder means handing out a live credential. How ToolRunner decodes and verifies tokens entirely in your browser.

by Dowon Oh

Every backend engineer I know has done this at least once: an API call fails with a 401, you grab the bearer token out of the request, and you paste it into the first "JWT decoder" result on Google to see what's inside. It takes ten seconds and it feels harmless, because a JWT looks like gibberish. But that gibberish is a live credential. If the decoder you pasted it into runs on someone else's server, you just transmitted a token that may still authenticate as you — or as your production service — to an endpoint you know nothing about. That is the entire reason ToolRunner's JWT Decoder exists, and why I built it to do every step — splitting, Base64URL decoding, JSON parsing, even HMAC signature verification — inside your browser tab, with no network request involved.

A JWT is a credential, not a puzzle

The mental trap is that JWTs feel encoded, and encoded feels like encrypted. It isn't. RFC 7519 defines a JWT as three Base64URL-encoded segments joined by dots — header, payload, signature — and Base64URL is a transport encoding, not a cipher. Anyone holding the token can read the header and payload with zero keys and zero effort. The signature at the end doesn't hide anything either; it only lets a party who holds the key confirm the first two segments weren't tampered with.

So decoding a JWT is trivial. What is not trivial is what the token can do while it's still valid. A typical access token in the wild carries claims like sub, scope, and exp, and until that exp timestamp passes, presenting the token to the issuing API is equivalent to presenting your password — better, actually, because it skips MFA. The OWASP JSON Web Token Cheat Sheet treats token sidejacking — someone else replaying a token they captured — as a primary attack vector for exactly this reason.

Now reread that first paragraph. Pasting a token into a server-backed decoder is voluntarily sidejacking yourself. The moment you hit decode on a site that processes tokens server-side, your credential is:

  • in that server's request logs, with a timestamp and your IP;
  • potentially in an analytics pipeline, a CDN cache log, or an error tracker;
  • retained for however long their log rotation policy says — which you have never read;
  • readable by every employee and every future attacker who breaches those logs.

Most tokens people debug are short-lived access tokens, and most decoder sites are presumably run by decent people. But "presumably decent" and "probably expired by the time anyone looks" is not a security posture. It's a habit that works until the day you paste a long-lived service-account token or a refresh token, and there is no way to un-paste it.

The fix is architectural, not behavioral

I don't think the answer is "be more careful." The answer is to use a decoder where the unsafe path doesn't exist. ToolRunner is a static site — there is no backend that could receive your token. The decode logic runs in the page you already loaded, and you can verify that with DevTools: open the Network tab, paste a token, and watch nothing happen.

The core of it is about fifteen lines. A JWT's segments use Base64URL (- and _ instead of + and /, padding stripped), and the browser's native atob only speaks standard Base64, so the first thing the decoder does is normalize:

function base64UrlDecode(str: string): string {
  let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
  const pad = base64.length % 4;
  if (pad) base64 += '='.repeat(4 - pad);
  return atob(base64);
}

The input is split on . and rejected with an explicit error unless there are exactly three parts. Then the header and payload segments each go through base64UrlDecode followed by JSON.parse, with separate error states so you can tell whether the header or the payload is the malformed half. atob is a synchronous, purely in-memory browser API — no fetch, no worker, no beacon. Decoding is debounced at 300ms so the tool re-parses as you type without churning on every keystroke.

Paste the sample HS256 token that ships with the tool and you get this back:

// Header
{
  "alg": "HS256",
  "typ": "JWT"
}

// Payload
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

One decoding decision worth calling out: the tool renders timestamp claims as humans read them, not as machines store them. Any numeric iat, exp, nbf, or auth_time claim gets an inline badge with the local date-time, and exp additionally gets a relative countdown — "Expires in 3 hours" in green, or "Expired 2 days ago" in red. In my experience the single most common reason anyone decodes a JWT at all is to answer "is this thing expired?", so that answer shouldn't require pasting a Unix timestamp into a second tool.

Signature verification without a server — and its honest limits

Decoding tells you what a token claims. It tells you nothing about whether those claims are trustworthy — RFC 7519 is explicit that the payload is unverified input until the signature checks out. So the tool also verifies signatures, and this is where staying client-side gets interesting, because verification requires the secret key. If pasting a token into a random server is bad, pasting the signing secret is catastrophically worse: the secret doesn't expire in an hour, and whoever holds it can mint arbitrary valid tokens.

ToolRunner verifies HMAC signatures using the browser's built-in Web Crypto API. The secret you enter (masked as a password field, with an optional "secret is base64 encoded" toggle for secrets stored that way) is imported as a raw HMAC key via crypto.subtle.importKey, and the tool re-signs the exact bytes of header.payload with crypto.subtle.sign:

const key = await crypto.subtle.importKey(
  'raw', secretBytes, { name: 'HMAC', hash: algo }, false, ['sign'],
);
const sig = await crypto.subtle.sign(
  'HMAC', key, new TextEncoder().encode(`${headerB64}.${payloadB64}`),
);

The computed signature is Base64URL-encoded and compared against the token's third segment; you get a green SIGNATURE VERIFIED or red SIGNATURE INVALID badge. HS256, HS384, and HS512 are supported, mapped to SHA-256/384/512 respectively.

And here is the honest limit: that's all it verifies. If the token's alg header says RS256, ES256, or anything else asymmetric, the tool shows you which algorithm it detected and displays an explicit notice — "Only HMAC algorithms (HS256, HS384, HS512) are supported for verification" — rather than pretending. Web Crypto is fully capable of RSA and ECDSA verification, and I may add public-key verification later since public keys are safe to paste by definition. But I would rather ship a tool that clearly states what it doesn't do than one that quietly skips the check and lets you believe a token was validated. A decoder that implies verification it never performed is worse than no decoder.

Two more things the tool deliberately does not do: it does not enforce exp/nbf as part of verification (it surfaces them visually and leaves the judgment to you, since debugging expired tokens is a legitimate use case), and it does not persist your secret anywhere — it lives in React state and is gone when you clear the form or close the tab.

The one caveat I'll flag myself: the share URL

Like most ToolRunner tools, the JWT Decoder syncs its input into the URL query string so you can bookmark or share a decoding session. That sync happens client-side via the History API — typing a token does not send it anywhere. But it means the token is in your address bar, and if you copy that URL into Slack, or someone later opens it, the token rides along — and an initial page load of a shared link does include the query string in the HTTP request. This is a genuinely useful feature for the sample tokens and for anything already-expired or synthetic. It is the wrong feature for a live production credential.

My own rule, and the one I'd suggest: share URLs freely for dummy tokens, and treat any URL containing a real token exactly like the token itself. The same logic applies to browser history on a shared machine. Client-side processing removes the server from the threat model; it doesn't remove you from it.

This is the same reasoning behind how I approach the rest of the site's security-adjacent tools — the Base64 encoder and hash generator follow the identical no-upload architecture, and it's the lens I used when writing about HTTP security headers: the best control is the one that makes the failure mode structurally impossible, not the one that asks everyone to remember a rule.

What I'd tell you to check before pasting a token anywhere

Before a JWT — yours or your service's — goes into any tool, mine included, run down this list:

  1. Is the decode happening locally? Open the Network tab and paste. If a request fires containing your token, close the tab. On /jwt-decoder/, nothing fires.
  2. Is the token still live? If exp is in the past, the blast radius of exposure is nearly zero. If it's a refresh token or a non-expiring service token, treat it like a password — because it is one.
  3. Never paste an HMAC secret into anything server-backed. Ever. A leaked access token expires; a leaked signing key is a full compromise of every token the issuer will ever mint until rotation.
  4. Did "verified" actually mean verified? Check which algorithm the token uses and whether the tool supports it. A green checkmark on an unsupported algorithm is a lie; this tool refuses to tell it.
  5. Check the URL before you share it. If the tool persists state in the query string — this one does — the URL is the token.

The ten-second decode habit isn't going away, and it shouldn't; inspecting tokens is a core debugging move. Just make sure the ten seconds happen entirely inside your own browser.

Sources

Specifications

OWASP

Mozilla MDN

Further reading