← Back to portfolio

Your JWT is a Postcard, Not an Envelope

securityjwtauthentication

Your JWT is a Postcard, Not an Envelope

Do this before you read anything else. Grab any JWT your app hands out and paste it into jwt.io. Look at what shows up. The middle section just unfolds into readable JSON: "roles": ["admin"], "email": "you@example.com", "user_id": 42, all of it sitting there in plain text. You didn't enter a password. The site didn't decrypt anything. And thats because there was nothing to decrypt.

That surprise is worth paying attention to, because it points at a misunderstanding that quietly shapes every decision about what you put in a token. So let me give you the one sentence to hold onto and then earn it:

A signed JWT is a postcard with a tamper-evident seal. Anyone who holds it can read it. What they can't do is change it without you noticing.

Here's the thing that trips everyone up: reading and changing feel like they should come as a pair, like if I can't change it I probably can't read it either. But a JWT splits them right down the middle. Reading? Wide open, come on in. Changing? Good luck. Let me show you how it pulls that off.

Why you can read it

A JWT is three chunks separated by dots:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzQyIiwicm9sZXMiOlsiYWRtaW4iXX0.signature_here

The header and payload aren't encrypted. They're encoded, which is a completely different thing, and conflating the two is the root of the misunderstanding. Encoding is just a reversible way of writing the same data in a different alphabet so it survives being passed around in URLs and headers. Anyone can reverse it. Encryption scrambles data so that only someone with a key can read it. JWTs use the first one, not the second.

The specific scheme is base64url, which is regular base64 with two characters swapped (+ and / become - and _) and the trailing = padding dropped, so the result is safe to drop into a URL without escaping. That's the only reason it looks like gibberish.

You can prove this to yourself in one line, no library, no key:

import base64, json

payload = "eyJzdWIiOiJ1c2VyXzQyIiwicm9sZXMiOlsiYWRtaW4iXX0"
# base64url needs its padding added back before decoding
print(json.loads(base64.urlsafe_b64decode(payload + "==")))
# {'sub': 'user_42', 'roles': ['admin']}

No secret involved. That's the whole point of the postcard image. The message is on the outside. Everyone in the mail system can read it.

Why you can't change it

So if anyone can read the payload, what stops an attacker from editing "roles": ["viewer"] to "roles": ["admin"] and sending it back? That's the tamper-evident seal, the third chunk, the signature.

Anatomy of a JWT

Here's the mechanism. When your server issues the token, it computes the signature over the exact bytes of the header and payload, using a key only the server holds. When a token comes back in a request, the server recomputes that signature over the header and payload it received, and checks that the result matches the signature attached to the token.

Now play the attacker. They decode the payload, flip viewer to admin, and re-encode it. The payload bytes are now different from the bytes the original signature was computed over. So when your server recomputes the signature, it doesn't match. Rejected. To produce a matching signature for the tampered payload, the attacker would need the server's signing key, which they don't have.

That's the seal. The message is readable by everyone and changeable by no one. Read, but don't write.

The trap this sets

Here's the practical consequence, and it's the reason this distinction is worth a whole post. Because the payload is readable by anyone, you must never put anything secret in it.

I have seen tokens carrying a user's password, a full credit card number, internal database IDs the company didn't want exposed, and in one case an API key for a different service. Every one of those is now readable by the user's browser, anything running in that browser, your proxy logs, your CDN, and anyone who ever gets a copy of the token. The signature does nothing to help here. It protects integrity, not secrecy. A sealed postcard is still a postcard.

So the rule is simple: a JWT payload should hold things that are fine for the user to see, because the user can see them. A user ID, their roles, the token's expiry. Identifiers and claims, not secrets.

It's worth knowing the standard names for the common claims, because every library and provider uses them and they show up in exp (expiry, a Unix timestamp), iat (issued at), sub (the subject, usually the user ID), iss (who issued it), and aud (the intended audience). These come from the JWT spec, RFC 7519. None of them are secret by design, which fits, since the whole payload is public anyway.

"But I actually do need the contents hidden"

Sometimes you genuinely need the token contents to be unreadable, not just unchangeable. The answer is not to get clever with the JWT you have. It's to use a different tool built for that job.

The signed JWT you've been looking at is technically a JWS (JSON Web Signature): signed, readable. There's a sibling spec called JWE (JSON Web Encryption) that produces an actually-encrypted token, where the payload is unreadable without a key. JWE tokens look different (they have five dot-separated parts, not three) and most "JWT" libraries support them through a separate code path.

But be honest with yourself before reaching for it. Most of the time the right move isn't to encrypt the token, it's to stop putting the secret in the token at all. Keep the sensitive data on your server, put a harmless reference in the JWT, and look the rest up when you need it. Encryption is for the rare case where the sensitive value genuinely has to travel inside the token itself.

The pattern to keep

Encoding is not encryption. A signed JWT proves who issued the data and proves nobody changed it, and it hides nothing. So treat every JWT payload as world-readable, because it is, and put only the things in it that you'd be comfortable printing on a postcard. When you need the contents actually hidden, that's a different tool (JWE) or, better, a different design (keep the secret on the server).

Also published on