What's Actually Inside a JWT (and What 'Decoding' Really Means)
JWTs look encrypted. They're not — by default, anyone can read every field. Here's exactly what a token contains, what the signature does and doesn't protect, and the mistake that follows from misunderstanding it.
A JSON Web Token looks like a wall of gibberish — three chunks of random-looking characters separated by dots. It's natural to assume that means it's encrypted. It isn't, by default, and understanding why matters if you're putting anything sensitive inside one.
The structure: three parts, one job each
A JWT is three Base64url-encoded segments joined by dots: header.payload.signature.
Header — a small JSON object naming the signing algorithm and token type, typically something like:
{ "alg": "HS256", "typ": "JWT" }
Payload — the actual content: a JSON object of claims. Some are standardized (sub for the subject/user ID, iat for issued-at time, exp for expiration time, iss for issuer), and the rest are whatever custom data the issuer decided to include — a username, a role, a permission list, anything.
Signature — computed over the header and payload using the algorithm named in the header, combined with a secret (for HMAC algorithms like HS256) or a private key (for asymmetric algorithms like RS256). This is the part that actually requires a secret to produce.
The part that surprises people: the payload is not encrypted
Base64url is encoding, not encryption — as covered here — which means decoding a JWT's header and payload requires no secret at all. Paste any JWT into a decoder (including ours) and you'll see the full claims immediately, signature or no signature, valid or forged. Browser DevTools, a five-line script, or literally just manually reversing the Base64url by hand all work equally well.
This is by design, not a flaw: JWTs are meant to be self-describing — a resource server can read the claims directly out of the token without a database round-trip, which is the entire performance benefit that makes JWTs popular for stateless auth in the first place. But it means you should never put anything in a JWT payload that the token's holder — or anyone who intercepts it — shouldn't be able to read. Passwords, full credit card numbers, or anything else genuinely secret has no business being a JWT claim.
So what does the signature actually protect?
The signature protects integrity and authenticity, not confidentiality. It answers one question: did this token come from someone holding the signing key, and has it been altered since?
If you change a single character of the payload — say, editing "role": "user" to "role": "admin" in the Base64url yourself — the signature no longer matches, and any server correctly verifying the token will reject it. That's the actual security property a JWT gives you: tamper-evidence, not secrecy.
"Decoding" vs. "verifying" — a distinction worth keeping straight
These are two different operations that get conflated constantly:
- Decoding just Base64url-decodes the header and payload back to JSON. It requires no key and proves nothing about the token's validity — a completely fabricated token decodes just as cleanly as a legitimate one.
- Verifying recomputes the signature using the correct key/secret and checks it matches. This is the step that actually confirms the token is genuine and unmodified — and it's the step a resource server must do before trusting anything in the payload.
A tool (or a developer) that only decodes a token and treats it as trusted without verifying the signature has skipped the one step that matters for security. This is a real, documented category of vulnerability — accepting an unverified or improperly-verified JWT (including the classic "alg: none" attack, where a forged token simply declares it isn't signed at all, and a naive verifier accepts that claim).
A few claims worth knowing
exp(expiration) — a Unix timestamp after which the token should be rejected, regardless of signature validity. Always check this, and keep expirations short for anything sensitive.iat(issued at) — when the token was created; useful for auditing and for enforcing a maximum token age independent ofexp.iss/aud(issuer / audience) — who issued the token and who it's intended for; checking these prevents a token meant for one service from being replayed against another.
None of these claims are secret. All of them are visible to anyone holding the token, which is exactly why the rule "never put sensitive data in a JWT payload" is worth internalizing rather than learning the hard way.
Curious what's actually in a token you're working with? Decode and inspect one — including HS256 signature verification if you have the secret.