A JWT has three parts, separated by dots: header.payload.signature. The header and payload are just Base64URL-encoded JSON — no secret needed to read them. That's the part a decoder shows you. The signature is the part that actually proves anything, and decoding does not check it.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0IiwiYWRtaW4iOnRydWV9.4pxTVi8... └──── header ────┘ └──────── payload ────────┘ └── signature ──┘
Decode the middle segment and you get something like:
{
"sub": "1234",
"admin": true
}Anyone with a text editor can produce that exact payload for any user ID, with "admin": true, and it will decode identically. The only thing standing between that forged token and your server treating it as legitimate is signature verification — checking the third segment against the secret (HS256) or public key (RS256) the server actually holds.
So what is a decoder actually for?
Debugging. You're looking at a token your own service issued, or one your API client received, and you want to see what claims are in it — expiry, scopes, subject — without writing a script. That's a legitimate and common need. What it is not for is deciding whether to trust the token. That decision belongs entirely to signature verification, on the server, using a library that does constant-time comparison and checks the algorithm field (never trust the alg claim in the token to tell you which algorithm to verify with — that's the classic alg: none vulnerability class).
One gotcha with expiry
The exp claim is a Unix timestamp in seconds, not milliseconds. If you're comparing it against Date.now() in JS, remember that returns milliseconds — you need Date.now() / 1000, or you'll think every token expired in 1970.