I was on a call with an engineer who was migrating their auth system to JWTs. They were excited about it. “It’s just simpler,” they told me. “No session store, no database lookups, everything lives in the token.” I asked what happens when they need to revoke one before it expires. They paused. “I’ll figure that out later.”

I’ve been thinking about that pause ever since.

You log in. The server hands you a token. From that point forward, you carry it with you everywhere you go. Every API (Application Programming Interface, the way two systems talk to each other) call, every page load, every action. That token is your proof that you’re who you say you are.

If that sounds like the session cookie from the last post, you’re not wrong.

Not quite.

A session cookie is like a coat check ticket. The stub means nothing on its own. The server has to walk to the back, find your coat, and confirm it belongs to you. The value lives on the server.

A JWT (JSON Web Token, a compact way of passing identity information between systems) is different. A JWT is a permission slip that carries its own proof. It says who you are, what you can do, and when it expires. All baked right into the token itself. The server doesn’t need to “look anything up.” It just reads the token and trusts it.

Why teams reach for JWTs

Remember the coat check ticket? Someone has to run that coat room. Every request means another trip to the back to match the stub to the coat. If you’ve got multiple venues, each one needs its own coat room, and your ticket only works at the place that gave it to you.

The permission slip doesn’t have that problem. Any door, any building, any service can read it and know what it says. No coat room needed. No database looking things up on every request. No worrying about which server you’re connected to. For systems built from many small, independent services, this feels like freedom. No central coat room. No bottleneck. You remove an entire class of headaches in one move.

So what’s the catch?

You can’t take it back

Here’s the thing about a permission slip that carries its own proof. Once you hand it out, it’s valid until it expires. There’s no “revoking” it in the traditional sense. With a session cookie, you delete the session from the database and you’re done. The user is logged out instantly.

With a JWT? The token’s still valid. The server will still accept it. The expiration timestamp says so, and the server trusts the timestamp.

This means if someone steals a JWT, you can’t just “invalidate” it the way you’d kill a session. You’d need to build a blocklist, check the blocklist on every request, and now you’re back to making a database call on every request. Which was the exact thing you were trying to avoid.

Congratulations. You’ve rebuilt the coat room. You’ve just put it behind a different door.

The “just make it short lived” argument

The standard advice is to make your JWTs expire quickly. Five minutes. Fifteen minutes. Pair them with a refresh token that lives longer and is stored more carefully.

This works, mostly. But now you have two tokens to manage instead of one. The refresh token becomes the real target. Steal the refresh token and you can generate new JWTs all day long. So the refresh token needs its own storage, its own rotation strategy. Rotation means swapping the token for a new one each time it’s used, so a stolen token can only work once. And then it needs its own revocation mechanism.

You’ve rebuilt the coat room again. You’ve just put it behind yet another door.

The payload isn’t a secret

New developers often assume that because a JWT looks like a random blob of characters, it must be encrypted. It’s not. The payload is Base64 encoded. That’s a way of converting data into text characters so it can travel easily. It’s not encryption. It doesn’t hide anything. Anyone with the token can decode it and read everything inside: a name, a role, an expiration time, all sitting there in plain text.

Think about what that means for the permission slip. Everything on it is written in plain ink. Your name, your role, what you’re allowed to do, when it expires. Anyone who picks it up can read the whole thing.

Teams routinely stuff user emails, roles, internal IDs, and sometimes even more sensitive data into the payload. That information travels with every single request, in a format anyone can read, often in a URL or a header that gets logged somewhere.

The signature at the bottom of the permission slip proves the data hasn’t been tampered with. It doesn’t hide what’s written above it. Those are two very different things.

So the payload is visible to anyone holding the slip. But at least the signature at the bottom guarantees it’s legitimate. Right?

What happens when someone forges the signature

The signature is the whole reason anyone trusts the permission slip. So what happens when someone figures out how to forge it?

Every JWT includes a header that tells the server which signing method to use. Yes, the permission slip itself gets to say how it should be checked. That design choice alone should make you uneasy. And it has led to one of the most well-known JWT vulnerabilities.

If an attacker changes that header to say “none” (meaning “no signature required”), some libraries will accept the slip without checking the signature at all. The permission slip says no verification is needed, and the system doesn’t question it.

There’s also the RS256 to HS256 confusion attack. RS256 uses RSA (Rivest-Shamir-Adleman, a public key cryptography system) to sign the token with a private key. HS256 uses HMAC (Hash-based Message Authentication Code, a way to verify data using a shared secret). The attacker switches the method and signs the token using the server’s public key, which is, by definition, public. The attacker forges the signature at the bottom of the slip using information that’s freely available. And if the library isn’t careful, it accepts the forged slip as valid.

These aren’t theoretical. They’ve been found in production systems, in major libraries, and in real applications handling real user data. And the barrier to pulling them off has dropped. What once required deep knowledge of the JWT specification now requires a well-worded prompt to an LLM (Large Language Model, an AI system trained on vast amounts of text) and a few minutes of iteration. The “it’s fine, nobody will figure this out” era ended when AI made offensive security knowledge accessible to anyone with a keyboard.

So should you use JWTs?

JWTs aren’t bad. But they’re a tradeoff, and most teams skip the tradeoff part.

If you’re building a single application with a database that’s already handling every request, you probably don’t need JWTs. A server-side session will be simpler, easier to revoke, and harder to mess up. If you’re building a distributed system (many small services working together across different servers) where multiple services need to verify identity without a central session store, JWTs make sense. But you need to go in with your eyes open. That means keeping lifetimes short, handling refresh tokens carefully, pinning the algorithm so your server only accepts the signing method you chose, having a plan for when a token needs to die before its time is up, and being able to see how tokens are actually being used across your system in real time, not just at the moment of issuance.

Using JWTs because they feel modern, without understanding the revocation tradeoff. Stuffing sensitive data into the payload without realizing it’s readable. Trusting the algorithm header without pinning it. That’s where things go wrong.

Most teams I’ve talked to can tell you they use JWTs. Very few can tell you what’s actually inside them, how long they live, or what happens in the fifteen minutes between a breach and an expiration. That engineer said they’d figure out revocation later. I keep wondering if fifteen minutes is what later looks like.

Previously on Off White Paper: The Flimsy Wristband explored why session cookies are the thing attackers want most. This post picks up where that one left off.