How to Decode a JWT (and Why Decoding Isn't Verifying)
A JWT is three base64url parts joined by dots. Decode the header and payload with any base64 decoder — but reading a token never proves it's genuine.
A JWT is three base64url-encoded segments joined by dots — header.payload.signature. To decode it: split on the dots and base64url-decode the first two segments (the third is binary signature bytes). No key is needed to read a token; decoding proves nothing about whether it’s genuine.
Anatomy, on the canonical example
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
| Part | Decodes to | What it is |
|---|---|---|
| header | {"alg":"HS256","typ":"JWT"} |
which algorithm signed it |
| payload | {"sub":"1234567890","name":"John Doe","iat":1516239022} |
the claims — identity + times |
| signature | 32 raw bytes (49 f9 4a c7 …) |
HMAC-SHA256 over header.payload |
The payload’s iat 1516239022 is seconds since epoch → 2018-01-18T01:30:22Z.
Decode it right now
With this site: paste just the payload segment (between the two dots) into the decoder — it accepts the URL-safe -_ alphabet and supplies missing = padding automatically, and reports which alphabet it saw.
In JavaScript, dependency-free:
const decode = (jwt) => {
const [, payload] = jwt.split('.');
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(decodeURIComponent(escape(atob(b64))));
// modern: JSON.parse(new TextDecoder().decode(
// Uint8Array.from(atob(b64), c => c.charCodeAt(0))))
};
Mind the alphabet swap — atob speaks standard base64 and chokes on -/_. The base64url explainer covers exactly why JWT chose it (+ and / are toxic inside URLs and filenames).
The claims table you’ll actually read
| Claim | Means | Type |
|---|---|---|
iss |
who issued the token | string/URL |
sub |
subject — the user/service ID | string |
aud |
intended audience | string or list |
exp |
expires at | epoch seconds |
iat |
issued at | epoch seconds |
nbf |
not valid before | epoch seconds |
jti |
unique token ID (replay checks) | string |
The caveat that keeps apps safe
Reading the payload is decoding. Trusting it is verifying — recomputing HMACSHA256(base64url(header) + "." + base64url(payload), secret) for HS256, or checking the RSA/EC signature for RS256/ES256, then checking exp/nbf. The famous JWT attacks — alg:none, HS256/RS256 confusion — all live in that verification step. If you found a JWT in a request and want to know what it says, decode it here; if your code wants to know whether to believe it, that happens server-side with the key.
Frequently asked questions
Can I decode a JWT without a library?
Yes — it's just base64url. Split the token on its two dots, take part 1 (header) or part 2 (payload), swap -→+ and _→/, pad to a multiple of 4 with =, and base64-decode. Or paste the whole token into the decoder — it accepts the URL-safe alphabet and missing padding natively, so you can drop in just the payload segment.
Is the JWT payload encrypted?
No — base64url is encoding, not encryption. Anyone holding the token can read every claim in it. Never put secrets, passwords or personal data you wouldn't show the user into a JWT payload. If you need confidentiality, you need a different spec (JWE) — signed JWTs (JWS) only guarantee integrity, not secrecy.
What's the difference between decoding and verifying a JWT?
Decoding reads the claims — free, keyless, tells you nothing about authenticity. Verifying recomputes the signature (HMAC with your secret for HS256; the public key for RS256/ES256) and checks expiry — the only step that proves the token is genuine and unmodified. A token you 'decoded successfully' could still be forged or expired.
What do exp, iat and the other claim names mean?
Registered claims (RFC 7519): iss issuer, sub subject (usually a user ID), aud audience, exp expiry, iat issued-at, nbf not-before, jti unique token ID. Times are NumericDate — seconds since the Unix epoch, UTC. Everything else in the payload is a custom claim the issuer invented.
Why do some JWT parts fail to decode?
Usual causes: you split on the wrong dot count (exactly two dots, three parts), you tried standard-base64 on a base64url segment (-/_ aren't in the standard alphabet — see base64 vs base64url), or the part needs = padding added back. The signature segment is binary — it decodes to 32/64 raw bytes, not readable JSON, and that's normal.
What does alg: none mean?
A token whose third segment is empty and whose header declares "alg":"none" is unsigned — the historical root of JWT bypass bugs where servers forgot to check the signature algorithm. Any server-side library should whitelist expected algorithms; 'none' should be refused outright.