JWT Debugger
Decode and verify JSON Web Tokens locally — nothing you paste leaves this tab.
decoded header appears here
decoded payload appears here
the algorithm and key check appear here
Decode, then verify
JWT tooling comes in two layers, and most tools blur the line between them.
Decoding is mechanical: a JWT is three base64url segments joined by dots, and the
first two are just JSON. Any script can split on the dot and decode them, which is why
“decode my JWT” pages are interchangeable — and why none of them are the
interesting part. Verifying is cryptographic: recompute the signature over the exact
header.payload bytes with the correct key, and compare. A token that decodes
cleanly tells you nothing about whether it is genuine. Anyone can encode
{"admin": true}; the signature is what says the issuer actually said it.
Here, decoding happens as you type. Paste a token and the header and payload appear,
pretty-printed, with exp, nbf and iat checked against
your clock and stated in plain language. The values are Unix seconds — same epoch,
same counting as any log line — and the
Unix timestamp converter is where a stray one of them goes to be re-read. Verification is a deliberate second
step: paste the
key — an HMAC secret, an SPKI public key, or a JWK — and press Verify (⌘⏎Ctrl+Enter). Both steps run entirely in your browser on the Web Crypto API, with no
library and no round trip. The page itself is served with connect-src 'none':
the browser refuses to let it make any network request at all. That matters here, because a
real JWT out of a production system is a credential — and this is a claim you can
check rather than one you have to take: read the policy off the
response header yourself.
Decode it without this tool
If you would rather decode in your shell than in a browser tab, these are the snippets that actually work. The first one carries a fix that took longer than it should have.
JWT='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…'
SEG=$(cut -d. -f2 <<< "$JWT")
printf '%s' "$SEG" | tr '_-' '/+' | awk '{print $0 substr("===", 1, (4 - length % 4) % 4)}' | base64 -d | jq .
The naive form everyone reaches for first is cut -d. -f2 <<< "$JWT" | base64
-d. It has two bugs, and the second is the interesting one. JWT segments use the
URL-safe alphabet (- and _ instead of + and
/), which BSD base64 happens to accept. They also omit the trailing
= padding, and on macOS and FreeBSD base64 -d does not fail on
unpadded input — it exits 0 and silently drops the final partial group. The
awk step above pads the segment to a multiple of four first, which fixes the
behaviour everywhere. The FAQ has the details.
import base64, json
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"
header, payload, _signature = token.split(".")
data = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
print(data)
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…';
const [, payload] = token.split('.');
// Buffer understands base64url natively on Node ≥ 16 — no manual padding.
console.log(JSON.parse(Buffer.from(payload, 'base64url')));
import jwt from 'jsonwebtoken';
try {
// Always pin the algorithms. Many historic JWT bugs came from
// accepting whatever alg the token claimed for itself.
const claims = jwt.verify(token, secret, { algorithms: ['HS256'] });
console.log(claims);
} catch (error) {
console.error(error.message); // "invalid signature", "jwt expired", …
}
How a JWT is put together
RFC 7519 defines the format: three segments — header, payload, signature — joined
by dots, each base64url-encoded JSON (padding dropped, which is the detail that keeps
breaking one-liners). RFC 7515 (JWS) defines how the signature is computed: over the exact
bytes header + "." + payload, so any reformatting before verifying breaks it.
RFC 7518 (JWA) defines the algorithms this page implements: HS256/384/512 (HMAC — one
shared secret, symmetric), RS256/384/512 and PS256/384/512 (RSA), ES256/384/512 (ECDSA), and
EdDSA. alg: none is a legal JWA value meaning “unsecured JWT” (RFC
7518 §3.6) — the FAQ explains why libraries that accepted it quietly produced a
series of serious vulnerabilities, and why this page refuses to present it as anything but
unsigned.
FAQ
Why does my base64 -d one-liner print a truncated payload with no error?
JWT segments are base64url without padding, and the base64 shipped on
macOS and FreeBSD does not fail on unpadded input — it exits 0 and silently drops the
final partial group. Measured on macOS, not recalled: a payload ending
{"sub":"12","exp":1516242622} decodes as
{"sub":"12","exp":151624262.
Skim the output and you see a plausible timestamp — wrong by a factor of ten. It
surfaces as an error only when something downstream like jq chokes on the
missing brace, which is easy to misread as a jq problem. The alphabet is not
the bug on BSD — base64 -d accepts - and _
happily — the padding is. GNU coreutils behaves differently; only the BSD variant was
available to measure here. Pad the segment before decoding, as in the shell snippet above,
and it works on both. (The base64 page carries the
same caveat in full, with the padded and unpadded decode options.)
What is the difference between decoding and verifying?
Decoding expands the base64url and pretty-prints two JSON documents; any three-part string
will decode. Verification recomputes the signature over the exact
header.payload bytes with a key, and fails unless it matches. A decoded token
may be real, may have been edited, or may carry a random signature — decoding alone
proves none of it. This page keeps the two steps separate, and only the second one ever
shows a green state.
Why is alg: none dangerous?
RFC 7518 §3.6 defines none for unsecured JWTs — no signature at all.
The danger is historical and real: for years, popular libraries accepted whatever
alg the header declared, so an attacker could set "alg":"none",
send a two-part token with the claims they liked, and have the server treat it as signed.
Modern libraries reject it unless explicitly allowed. This page decodes alg: none
tokens but never shows them as verified — there is no signature to check, and no UI
state that could be misread as one.
Why does ES512 use curve P-521, not P-512?
The number in ES256/384/512 is the SHA digest size in bits, not the curve size. ES512 pairs SHA-512 with the P-521 curve — named for its 521-bit prime, the largest standardised NIST curve. The pairing looks like a typo, and it is the most common thing people get wrong reading the algorithm name, but it is correct per RFC 7518 §3.4. No standardised P-512 curve exists.
Can this page fetch my JWKS by kid?
No, and it is architectural, not missing. This page is served with
connect-src 'none': the browser blocks every network request it could make,
which is the property that makes it safe to paste a production token into. Fetching a JWKS
URL would mean widening that policy to make the request, to save you one paste. Paste the
public key directly instead — and if the token carries a kid, it is shown
in the decoded header.
Is it safe to paste a production token or signing secret here?
The token in your clipboard is usually a live credential, and the HMAC secret you paste to
verify it is the thing that mints more of them — so it is a fair question to ask of
any JWT site, including this one. Two answers. The mechanical one: this route is served
with connect-src 'none', so the browser itself refuses to let the page make a
request, whatever the code tries. The badge under the tool prints the policy actually being
sent, and how to read it off the response yourself is a page of its
own. The design one: this page has no Copy link button, unlike the other tools
here, because a shareable link would mean writing your token into a URL that outlives the
tab. Verification runs against the key in the box, and nothing is kept once you close it.