skip to main content

-- Inspect the header, payload and dates of a JWT without the token ever leaving your browser --

-- everything is decoded in your browser: no token is sent over the network --

Usage

Paste a JWT to read its header and payload straight away. The iat, nbf and exp claims are converted to readable dates, and an expired token is flagged.

Everything is decoded in your browser: the token is never sent over the network. That matters, because a JWT is an authentication credential — pasting it into an online tool that ships it to a server is equivalent to leaking a session ID.

Decoding is not verifying

A JWT has three dot-separated parts:

<header>.<payload>.<signature>

The first two are base64url — an encoding, not encryption. Anyone can read them. The third is the signature, and it alone guarantees the token has not been tampered with.

This tool does not verify the signature: that would require the secret key, which has no business being in a browser. In other words, what you see here is what the token claims to be. On the server, always validate the signature before trusting a single claim.

Standard claims

ClaimMeaning
isstoken issuer
subsubject, usually the user ID
audintended audience
expexpiration date
nbfdate before which the token is not valid
iatissued-at date
jtiunique token ID

Dates are expressed in seconds since the Unix epoch, not milliseconds. Mixing the two up yields expiry dates in 1970 or several millennia from now.

Decoding a JWT in JavaScript

const [header, payload] = token
  .split(".")
  .slice(0, 2)
  .map((part) => {
    const base64 = part.replaceAll("-", "+").replaceAll("_", "/");
    const padded = base64.padEnd(
      base64.length + ((4 - (base64.length % 4)) % 4),
      "="
    );
    return JSON.parse(atob(padded));
  });

The padding has to be restored: JWTs omit trailing = characters, while atob expects them.

Verifying a JWT in Node.js

import { createHmac, timingSafeEqual } from "node:crypto";
 
const [rawHeader, rawPayload, signature] = token.split(".");
const expected = createHmac("sha256", secret)
  .update(`${rawHeader}.${rawPayload}`)
  .digest("base64url");
 
const valid = timingSafeEqual(
  Buffer.from(signature),
  Buffer.from(expected)
);

The comparison goes through timingSafeEqual: a plain === short-circuits on the first differing byte, which leaks exploitable timing information.