A JWT has three parts separated by dots: header.payload.signature. The header and payload are Base64URL-encoded JSON, and you don't need a secret to read them. That's what a decoder shows you. The signature is the part that proves anything, and decoding doesn't 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 the same way. The only thing that stops your server from treating that forged token as legitimate is signature verification: checking the third segment against the secret (HS256) or public key (RS256) the server holds.
What a decoder is for
Debugging. You're looking at a token your own service issued, or one your API client received, and you want to see its claims (expiry, scopes, subject) without writing a script. That's a common, legitimate need. Deciding whether to trust the token is a different job. It belongs to signature verification on the server, using a library that does constant-time comparison and checks the algorithm field. Never let the alg claim in the token 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. Date.now() in JS returns milliseconds, so compare against Date.now() / 1000, or you'll think every token expired in 1970.