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.

Published 2026-10-06

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.