DEV Community

Dev Nestio
Dev Nestio

Posted on

I Built a JWT Decoder That Never Sends Your Token to a Server

Pasting a JWT into an online decoder is a surprisingly bad idea. The payload is just base64url — not encrypted. Whatever is in there (user ID, email, roles, scopes) is visible to whoever runs that server.

I built a JWT Decoder & Inspector that decodes entirely in your browser, with no network requests.

👉 https://jwt-decoder.pages.dev

What It Shows You

Paste any JWT and you get:

  • Header — algorithm (alg), token type, key ID
  • Payload — all claims, formatted as pretty-printed JSON
  • Signature — the raw base64url value (not verified — more on that below)

Timestamp claims (exp, iat, nbf) are converted from Unix seconds to human-readable local time. The expiration status is shown explicitly:

exp: 2026-07-01 09:00:00 (local)  ✅ Valid
Enter fullscreen mode Exit fullscreen mode

or

exp: 2026-06-01 09:00:00 (local)  ⚠️ Expired
Enter fullscreen mode Exit fullscreen mode

The base64url Trap

The first implementation detail that trips people up: JWT uses base64url, not standard base64.

The differences:

  • +-
  • /_
  • Trailing = padding is omitted

Run atob() directly on a base64url string and you'll get an exception or garbage. You need to convert first:

function decodeBase64Url(str) {
  let b64 = str.replace(/-/g, "+").replace(/_/g, "/");
  while (b64.length % 4) b64 += "=";
  const bin = atob(b64);
  const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
  return new TextDecoder("utf-8").decode(bytes);
}
Enter fullscreen mode Exit fullscreen mode

The second trap: multibyte characters. atob() returns a binary string, not a JavaScript string. If the payload contains non-ASCII content (names in non-Latin scripts, emoji), you'll get mojibake unless you pipe the bytes through TextDecoder. This produces subtle, hard-to-reproduce bugs — works fine for most tokens, breaks on specific ones.

Timestamp Math

JWT timestamps are Unix seconds, not milliseconds. The Date constructor takes milliseconds. Off-by-1000x gives you a date in 1970:

const exp = payload.exp ? new Date(payload.exp * 1000) : null;
const isExpired = exp ? exp.getTime() < Date.now() : false;
Enter fullscreen mode Exit fullscreen mode

Obvious in hindsight, but the bug appears constantly.

Why the Decoder Doesn't Verify Signatures

This was a deliberate design choice: no signature verification.

To verify a JWT signature you need either the secret (HS256) or the public key (RS256/ES256). Adding a "paste your secret here" form would undermine the whole point of the tool. You'd be putting your signing material into a browser text box, which is exactly the kind of thing this tool is trying to avoid.

The tool's job is decoding — reading what's inside the token. Verification belongs in your backend library. The UI makes this explicit with a notice: "Signature is not verified."

Keeping scope narrow makes the tool trustworthy.

No Framework, No Dependencies

JWTs have three parts separated by dots. Splitting on . and decoding each part doesn't need React. A single HTML file with vanilla JS is faster to load, has no CDN dependencies to audit, and works offline from browser cache.

Validated with 31/31 passing tests: base64url edge cases (with and without padding, containing - and _), multibyte payloads, expired vs. valid expiration detection, and malformed JWTs (missing parts, corrupted base64) that display errors instead of throwing exceptions.

Try It

Next time you're debugging auth and need to check what's actually in a token:

👉 JWT Decoder & Inspector — devnestio

All tools: https://devnestio.pages.dev

Works offline. Your token never leaves your browser.

Top comments (0)