April 11, 2026

A JWT Debugger That Never Sends Your Token Anywhere

A JWT Debugger That Never Sends Your Token Anywhere jwt.io says your token never leaves...

By SEN LLC (@sendotltd) • 4 min read
A JWT Debugger That Never Sends Your Token Anywhere

A JWT Debugger That Never Sends Your Token Anywhere

jwt.io says your token never leaves the browser. You're supposed to take their word for it. I wanted something I could prove by looking at the build — so I wrote one with connect-src 'none' in its CSP header. That's an enforcement, not a promise.

Every time I paste a production JWT into jwt.io, I get a tiny pang of discomfort. Yes, the official site documents that decoding happens client-side. Yes, I believe them. But I have no way to verify that they keep doing that tomorrow. I wanted a tool where "doesn't send your token anywhere" isn't a documentation claim — it's enforced by the browser.

🔗 Live demo: https://sen.ltd/portfolio/jwt-debugger/
📦 GitHub: https://github.com/sen-ltd/jwt-debugger

Screenshot

Three-panel layout (Header / Payload / Signature), time claims rendered as Unix + ISO + relative delta, expired/not-yet-valid warnings, UTF-8 safe. Vanilla JS, zero dependencies, no build step, no network calls anywhere in the source.

Proving "doesn't phone home" without making you audit the source

A user can't reasonably audit JavaScript for absent fetch calls. But the browser can enforce it for them, with one CSP directive:

<meta http-equiv="Content-Security-Policy" content="
  default-src 'self';
  connect-src 'none';
  img-src 'self' data:;
">
Enter fullscreen mode Exit fullscreen mode

connect-src 'none' blocks fetch, XMLHttpRequest, WebSocket, EventSourceall at the browser level. Even if future code (or a compromised dependency, or malicious edits) tries to exfiltrate the token, the browser refuses. That single line is a stronger guarantee than asking anyone to trust the source.

In production it's better to ship this as an HTTP response header so it can't be stripped by HTML edits. The meta-tag version works for a static bucket, which is where I host it.

JWT segments are Base64*url*, not Base64

Each of the three dot-separated segments uses the base64url variant of RFC 4648:

  • - instead of +
  • _ instead of /
  • Trailing = padding is optional

It's the URL-safe cousin, designed so the token doesn't need percent-encoding to fit in a query string. Decoding means reversing the substitutions and reattaching padding:

export function base64UrlDecode(s) {
  let b64 = s.replace(/-/g, '+').replace(/_/g, '/')
  const pad = b64.length % 4
  if (pad === 2) b64 += '=='
  else if (pad === 3) b64 += '='
  else if (pad === 1) throw new Error('invalid base64url length')

  const binary = atob(b64)
  const bytes = new Uint8Array(binary.length)
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
  return new TextDecoder().decode(bytes)
}
Enter fullscreen mode Exit fullscreen mode

The important part is the TextDecoder at the end. atob returns a string where each char is one byte of the underlying data, not a properly decoded UTF-8 string. Skip the Uint8Array + TextDecoder step and any multi-byte claim turns into mojibake:

// Token contains { "name": "山田太郎" }
{ name: "山田太郎" }  // Without TextDecoder
{ name: "山田太郎" }      // With TextDecoder
Enter fullscreen mode Exit fullscreen mode

The pad === 1 check is also deliberate — a base64url payload of length 4k+1 can't exist because the encoding maps 3 bytes to 4 characters. If you see it, the token is truncated.

Time claims: the "relative delta" is the whole point

JWT time claims are Unix seconds (not milliseconds — catches people constantly):

  • exp — expires at
  • iat — issued at
  • nbf — not before
  • auth_time — authentication time (OIDC)

Looking at 1721462400 tells you nothing, so the UI shows all three representations:

annotations[claim] = {
  value: ts,                                    // 1721462400
  iso: new Date(ts * 1000).toISOString(),       // 2024-07-20T08:00:00.000Z
  delta: ts - nowSec,                           // -3600 (1 hour ago)
}
Enter fullscreen mode Exit fullscreen mode

The UI then turns delta into a human relative time ("5 minutes ago", "in 2 hours"). When debugging live tokens, the single question I always have is "is this token still valid right now?" — and that's exactly what delta answers.

Expired and not-yet-valid conditions become explicit warnings:

if (annotations.exp && annotations.exp.delta <= 0) {
  warnings.push(`exp: expired ${-annotations.exp.delta}s ago`)
}
if (annotations.nbf && annotations.nbf.delta > 0) {
  warnings.push(`nbf: not valid for another ${annotations.nbf.delta}s`)
}
Enter fullscreen mode Exit fullscreen mode

Deliberately no signature verification

This tool does not verify the signature. That's not an oversight — it's a design choice.

  1. Verifying requires a secret (for HS*) or a public key (for RS*/ES*). Asking the user to paste a secret into a browser tool defeats the privacy argument for building this at all.
  2. I don't want to encourage a workflow where people habitually paste HS256 server secrets into browser inputs.
  3. Signature verification belongs on the server. If a client-side tool says "✓ valid", users might assume that means something authoritative. It doesn't.

The tool's job is showing you what's in the token. Whether the token is properly signed is the server's job.

Per-segment error messages

JWT parsing can fail at multiple stages, and you want to know exactly where:

return { ok: false, error: 'token must be a string' }
return { ok: false, error: `expected 3 segments, got ${parts.length}` }
return { ok: false, error: `header: ${e.message}` }
return { ok: false, error: `payload: ${e.message}` }
return { ok: false, error: `header JSON: ${e.message}` }
return { ok: false, error: `payload JSON: ${e.message}` }
Enter fullscreen mode Exit fullscreen mode

Most parse errors in real life are "I copy-pasted and the tail got clipped" or "this got truncated in a log." If you get "expected 3 segments, got 2", the signature is missing. If you get "payload JSON: Unexpected end of input", the payload got truncated. That's the debugging information you actually want.

Tests

13 cases on node --test:

  • Valid HS256 / RS256 tokens
  • Base64url -_ character substitution
  • Zero / one / two padding characters
  • Japanese payload round-trip
  • Wrong segment count
  • Invalid base64 character
  • Malformed JSON
  • exp / nbf warning logic
npm test
Enter fullscreen mode Exit fullscreen mode

Series

This is entry #6 in my 100+ public portfolio series.

Security-focused feedback especially welcome.

Ready to build your next web project? Let's work together.