Skip to content

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:

text
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.

JunoThree parts, one dot-separated string A wax seal on an envelope is a fair comparison. Anyone can look at the seal, and only the person with the stamp can make one.

Which means the seal doesn't hide the letter. It proves who closed it, and that nobody opened it since.

JunoThree parts, one dot-separated string Paste a real token into a decoder and you'll see the header and payload in plain JSON immediately, no secret required. Do that once with a token from something you're building; it makes the next section land properly.

The signature is the part that resists you. It only tells you yes or no, and only if you hold the secret.

JunoThree parts, one dot-separated string The header being attacker-visible and attacker-supplied is the source of the classic JWT forgery. Set alg to none, drop the signature, and any server decoding a token instead of verifying it accepts a payload you chose.

That has been demonstrated against a jwt.decode middleware, and it produces a valid session as whoever you claimed to be.

The fix is to pin what you accept in jwt.verify: name the algorithms explicitly, and pin issuer and audience too. Never let the token tell you how to check it.

One unit to remember while you're there: clockTolerance is in seconds, not milliseconds.

The flow, and what changed

  1. The user logs in with a username and password.
  2. The server checks them.
  3. On success, the server creates a JWT and signs it.
  4. The token goes to the client, which stores it.
  5. Every later request carries it in the Authorization header, exactly as an opaque token would.
  6. 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 tokenJWT
ContentsNothing, a random stringEncoded claims: user id, role, expiry
To identify the userLook it upVerify the signature, read the claims
Server storageA small token-to-user tableNone
Where identity livesOn the serverInside the token

An opaque token points at identity held on the server. A JWT contains it.

JunoThe flow, and what changed Only one step changed, and it's the one that decides everything else.

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.

JunoThe flow, and what changed No storage means no shared session store to run, no cross-instance coordination, and horizontal scaling for free. That's a genuine operational win and it's why JWTs are the usual pick for microservices.

The cost lands on the day you need to end a session immediately. There is no record to delete, so the token keeps working until it expires. Plan for that before you need it rather than during an incident.

JunoThe flow, and what changed Roles inside the token are the sharp edge in practice. Demote an admin and their existing token still says admin until expiry, so your permission change has a delay measured in whatever lifetime you chose.

Two ways out, and they cost different things. Keep tokens very short-lived, minutes, and refresh often, which reintroduces a lookup on refresh. Or keep an allowlist or denylist of token ids, which reintroduces the lookup you removed.

Worth being honest that both are the stateless model buying back a bit of state. That is a fine design, and it is not the free win the pitch describes.

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 wrongWhy it mattersThe fix
1Assuming it's encryptedIt's encoded, not encrypted. Anyone holding it can read the payload. Information disclosure in STRIDE, sensitive data exposure in OWASPPut nothing in the payload you wouldn't write on a postcard
2A weak or leaked secretGuess or steal the secret and an attacker signs their own tokens, including one that says admin. Elevation of privilegeA long random secret, loaded from the environment, never committed
3An overstuffed payloadBig tokens mean big headers on every request: slow requests, unhappy proxies, bloated logs. And everything in there leaks if the token doesOnly what the server needs on every request
4No expiryYou cannot revoke a JWT, so without an expiry it works forever. Broken authenticationAlways 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.

JunoFour ways it goes wrong Pitfall one catches nearly everyone, because a JWT does look like scrambled nonsense.

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.

JunoFour ways it goes wrong The secret belongs in an environment variable and out of the repository, and "we'll rotate it later" needs a plan, because rotating invalidates every live token at once.

Supporting two valid secrets during a rotation window is what makes it survivable: verify against both, sign with the new one, then retire the old.

JunoFour ways it goes wrong The choice of algorithm decides who can mint tokens. HS256 is symmetric, so the same secret signs and verifies, and every service that checks a token can also create one. RS256 is asymmetric: one service holds the private key and signs, everyone else verifies with the public key.

For a single application HS256 is fine. Across services, symmetric signing quietly grants every verifier the power to forge, which is rarely what anyone intended.

Also worth knowing that a JWT does not have to be an access token. It's a container format, and using one as a password reset link or an email verification token is common and sensible.

Each needs a purpose claim, so a token minted for one job cannot be presented for another.

Try it

A stateless security audit, four scenarios.

js
// 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
#PitfallFix
1Long-lived token. Expires in the year 3000, almost certainly a typo, and still a working credential for a millenniumAn expiry 30 minutes out
2No revocation. It finds the record and returns it without ever looking at expires, so an expired token authenticatesCompare expires against now, return null past it, and delete the row while you're there
3Overstuffed payload, and information disclosure. A street address and phone number sit in something any holder can read, alongside analytics the server never needsid, name and admin, nothing else
4Weak secret, and no expiry. secret123 is guessable and it's hardcoded, so it's in the repositoryA 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.