JSON Web Tokens
An opaque token needs a lookup: the string means nothing, so the server has to ask its own storage who it belongs to.
Take away the lookup and something has to replace it. A JSON Web Token, or JWT, replaces it by putting the answer inside the token.
Three parts, one dot-separated string
A JWT looks like line noise and has a strict structure. Three parts, joined by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjQ4MjEsInJvbGUiOiJ0ZWFjaGVyIn0.4pcPyMD09olPSyXnrXCjTwXyr4BsezdI1AVTmud2fU4
└────────── header ──────────┘ └────────── payload ─────────┘ └────────── signature ──────────┘- Header. Which algorithm signed it, and that this is a JWT.
- Payload. The identity information, called claims: who the user is, what role they hold, when the token expires, who issued it.
- Signature. A cryptographic stamp proving nothing has been altered.
The server builds it by taking the header and payload, combining them with a secret only it knows, and producing the signature. All three parts are then encoded and joined.
Anyone can read a JWT. Only someone with the secret can make one the server will accept.
That's what makes it trustworthy without a lookup, and it's the whole idea in one sentence.
Which means the seal doesn't hide the letter. It proves who closed it, and that nobody opened it since.
The flow, and what changed
- The user logs in with a username and password.
- The server checks them.
- On success, the server creates a JWT and signs it.
- The token goes to the client, which stores it.
- Every later request carries it in the
Authorizationheader, exactly as an opaque token would. - The server verifies the signature and reads the claims. No lookup, no storage.
Step six is the only one that differs from opaque bearer tokens, and it changes the model completely:
| Opaque bearer token | JWT | |
|---|---|---|
| Contents | Nothing, a random string | Encoded claims: user id, role, expiry |
| To identify the user | Look it up | Verify the signature, read the claims |
| Server storage | A small token-to-user table | None |
| Where identity lives | On the server | Inside the token |
An opaque token points at identity held on the server. A JWT contains it.
Looking something up means the server can check the latest answer. Reading the token means the server gets the answer that was true when the token was made.
Four ways it goes wrong
A JWT is still a bearer token, so every opaque token pitfall still applies: steal it and you become the user. Being self-contained adds four more.
| What goes wrong | Why it matters | The fix | |
|---|---|---|---|
| 1 | Assuming it's encrypted | It's encoded, not encrypted. Anyone holding it can read the payload. Information disclosure in STRIDE, sensitive data exposure in OWASP | Put nothing in the payload you wouldn't write on a postcard |
| 2 | A weak or leaked secret | Guess or steal the secret and an attacker signs their own tokens, including one that says admin. Elevation of privilege | A long random secret, loaded from the environment, never committed |
| 3 | An overstuffed payload | Big tokens mean big headers on every request: slow requests, unhappy proxies, bloated logs. And everything in there leaks if the token does | Only what the server needs on every request |
| 4 | No expiry | You cannot revoke a JWT, so without an expiry it works forever. Broken authentication | Always set one, and keep it short |
Pitfall 2 is the one that turns a small mistake into a total compromise. A leaked session id is one account; a leaked signing secret is every account, including ones that don't exist yet.
It isn't scrambled. It's written in an alphabet that isn't friendly to read, and any decoder turns it straight back into plain text.
Try it
A stateless security audit, four scenarios.
// 1. A record in the token lookup
const tokenTable = [
{ token: 'a91f...', userId: 4821, expires: '3000-01-01T00:00:00Z' },
]
// 2. Called on every request
function authenticate(token) {
const record = tokenTable.find((r) => r.token === token)
return record ?? null
}
// 3. The payload going into a JWT
{ id: 4821, name: 'Mara', admin: false, streetAddress: '14 Rue Lepic',
phone: '+33 1 45 22 88 01', tabSwitches: 14, windowResizes: 3 }
// 4. Server config used when issuing JWTs
const config = { secret: 'secret123', algorithm: 'HS256' }Compare your answers
| # | Pitfall | Fix |
|---|---|---|
| 1 | Long-lived token. Expires in the year 3000, almost certainly a typo, and still a working credential for a millennium | An expiry 30 minutes out |
| 2 | No revocation. It finds the record and returns it without ever looking at expires, so an expired token authenticates | Compare expires against now, return null past it, and delete the row while you're there |
| 3 | Overstuffed payload, and information disclosure. A street address and phone number sit in something any holder can read, alongside analytics the server never needs | id, name and admin, nothing else |
| 4 | Weak secret, and no expiry. secret123 is guessable and it's hardcoded, so it's in the repository | A long random secret from an environment variable, plus an expiry claim |
Scenario 2 is the one that hides best. The function looks correct, it does find the token and it does return null for one that doesn't exist. Storing an expiry and never checking it is the same as having no expiry at all.
Where this goes next
Two stateless models, one trading a lookup for revocation and the other trading revocation for scale. Both still make your app responsible for passwords, resets and the whole apparatus of proving who somebody is.
OAuth and delegated identity asks whether you want that job at all.

