To decode a JWT, split it on its two dots and Base64URL-decode the first two parts — or skip the manual work and paste it into the JWT Decoder, which shows the header and payload as formatted JSON without the token leaving your browser. Decoding takes seconds and requires no key, and that fact is the most important thing to understand about JWTs: anyone can read one. Decoding is not verification.
The three segments
A JWT (RFC 7519) is three Base64URL blobs joined by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiI0MiIsImV4cCI6MTc2NzIyNTYwMH0 . SflKxwRJ... └── header ─────────────────────────┘ └── payload ──────────────────────────┘ └─ signature ─┘
- Header — metadata, typically
{"alg": "HS256", "typ": "JWT"}: which algorithm signed the token. - Payload — the claims: registered ones like
sub(subject),exp(expiry),iat(issued at),iss(issuer), plus whatever custom claims the issuer added. - Signature — cryptographic proof over the first two segments. It is not decodable into anything readable; it can only be verified with the right key.
Decoding it yourself
The segments are Base64URL, not standard Base64 — - and _ replace + and /, and padding is dropped (see Base64 vs Base64URL). That is why atob() on a raw segment sometimes throws. In JavaScript:
const payload = JSON.parse(
atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))
);The decoder tool does exactly this — plus JSON pretty-printing — client-side.
A worked example
Here is a complete (expired, HS256-signed) token and what falls out of it:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
segment 1 decodes to: {"alg":"HS256","typ":"JWT"}
segment 2 decodes to: {"sub":"1234567890","name":"John Doe","iat":1516239022}
segment 3: the HMAC-SHA256 signature (not decodable)Reading the claims: sub identifies the subject, name is a custom claim the issuer chose to add, and iat: 1516239022 is Unix seconds — converted, 2018-01-18T01:30:22Z, so this token is years old. There is no exp claim at all, which itself is a finding: a token without exp never expires unless the server enforces its own lifetime. The registered claims worth knowing on sight: iss (who minted it), aud (who it is for), sub, exp/nbf/iat (the three timestamps), and jti (unique token ID, used for revocation lists).
What decoding tells you (a lot)
Debugging auth usually only needs the payload: Is the token expired? (exp is Unix seconds — convert it and compare with now.) Was it issued for the right user (sub), audience (aud) and scopes? Did the identity provider include the custom claim your code reads? Nine out of ten “my API returns 401” investigations end at an expired exp or a missing claim.
What decoding cannot tell you
Whether the token is genuine. Anyone can mint a JWT with any claims — alg set to HS256, sub set to admin — and it will decode beautifully. Only signature verification against the issuer's key separates a real token from a forged one, which is why servers must verify (never just decode) and why a browser decoder honestly labels the signature “not verified”. Treat decoded claims as a debugging view, never as an authorization decision.
The alg field is attacker-controlled
One header claim deserves special paranoia: alg. RFC 7519 permits "alg": "none" — an unsigned JWT whose third segment is empty (the token ends with a dot and nothing after it). Early JWT libraries famously honored whatever alg the token declared, so an attacker could strip the signature, set none, and sail through verification; a related classic swapped RS256 for HS256 to trick servers into using a public key as an HMAC secret. Modern libraries force you to allowlist algorithms — do that, server-side, always. From the decoding side, the lesson is simpler: everything you can read in the header and payload, an attacker can write.
