Skip to content
wannacode

JWT and its traps

In short: A JWT is a signed token made of a header, a payload and a signature; the payload is not encrypted and anyone can read it, and the server must check the algorithm, signature and exp itself.

Problem

The token that became admin

The mobile app of the shop does not use pages but an API. The team chooses JWT, "so the server does not have to keep sessions", and copies the token check from an old blog post. Months later a security researcher writes to the shop: with a token he wrote by hand, he got into the refund API as admin.

The shop key has not leaked. Neither has the admin password. The researcher broke nothing: the check accepted his token by itself.

The idea

A signed pass

The session from the previous lesson is a cloakroom ticket: the server keeps a table of who is behind each id. A JWT (JSON Web Token) is a different approach: the token itself says who you are and until when it is valid, and the server signs it so that it cannot be changed.

The token has three parts, separated by dots:

  • header: how it is signed, for example {"alg":"HS256","typ":"JWT"};
  • payload: what it says, for example {"sub":"maria","exp":1793000900}. sub is the user, exp is the moment it expires, in seconds since 1 January 1970;
  • signature: HMAC-SHA256 over "header.payload" with a secret key of the server.

The header and the payload are JSON written in base64url: base64 with - and _ instead of + and / and without = at the end, so that it fits in an address and a header. Base64url is a way of writing, not encryption. The token is signed, not secret: anyone reads it, but without the key nobody can change it without the signature no longer matching.

See it

The three parts and the forged token

function verify(token, now) {
const parts = token.split(".");
if (parts.length !== 3) return "✗ format";
// the algorithm is pinned: never taken from the token
const { alg } = JSON.parse(decode(parts[0]));
if (alg !== "HS256") return "✗ algorithm";
const data = parts[0] + "." + parts[1];
if (!same(sign(data), parts[2])) return "✗ signature";
const claims = JSON.parse(decode(parts[1]));
if (typeof claims.exp !== "number" || now >= claims.exp)
return "✗ expired";
return "✓ " + claims.sub;
}
Step by step, in words
  1. The header says how the token is signed: HS256. The JSON becomes base64url text.
  2. The payload says who and until when: sub maria, exp in 15 minutes. Also base64url.
  3. The signature is HMAC-SHA256 over "header.payload" with the key of the server. Without the key nobody can make it.
  4. The token is the three parts with dots. The mobile app sends it with every request.
  5. Anyone who has the token reads the payload without a key: base64url is not encryption. Signed, not secret.
  6. Now a forged token: the header says alg none, the payload says sub admin, and there is no signature.
  7. The naive check asks the token which algorithm to use. "none" skips the signature, and admin gets in.
  8. The fix pins the algorithm: only HS256, decided by the server. alg none is rejected before anything else.
  9. A genuine but old token: the signature is right, but exp has passed. The naive check let it in; this one does not.
  10. Three checks, in this order: the algorithm the server chose, the signature, the expiry. The payload is never a secret.

The signature and base64url are in the standard library of every language. The key here is a demo: the real one is 32 random bytes kept outside the code (the lesson "Secrets").

const KEY = "demo-key-only-for-this-lesson"; // a demo key; the real one is 32 random bytes from a secret store
const encode = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url");
const sign = (data) => createHmac("sha256", KEY).update(data).digest("base64url");
function issue(user, exp) {
const header = { alg: "HS256", typ: "JWT" };
const payload = { sub: user, exp };
const data = encode(header) + "." + encode(payload);
return data + "." + sign(data);
}
JSON.stringify writes without spaces, and Buffer and createHmac from node:crypto know base64url ("base64url").

A token for Maria, valid for 15 minutes:

const now = 1_793_000_000;
const token = issue("maria", now + 900);
const parts = token.split(".");
console.log("header: " + parts[0]);
console.log("payload: " + parts[1]);
console.log("signature:" + parts[2]);
console.log("payload without the key:", decode(parts[1]));
Output
header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
payload: eyJzdWIiOiJtYXJpYSIsImV4cCI6MTc5MzAwMDkwMH0
signature:mXfW7XuQCRb8pXyoR6d687Dzn8zr5Y5uET-UFxEvOKw
payload without the key: {"sub":"maria","exp":1793000900}

The key and "now" are fixed, so the token is the same on every run and in all four languages. The last line is the payload, read without the key.

Try it

Predict the output

Someone has caught Maria's token. There is no key. What does this print?

const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYXJpYSIsImV4cCI6MTc5MzAwMDkwMH0.mXfW7XuQCRb8pXyoR6d687Dzn8zr5Y5uET-UFxEvOKw";
const payload = token.split(".")[1];
console.log(Buffer.from(payload, "base64url").toString());
Answer

The idea

Trap 1: the algorithm comes from the token

Here is the check from the blog post. To read the header and the payload it uses two helpers:

const decode = (part) => Buffer.from(part, "base64url").toString();
// NOT LIKE THIS: trusts the algorithm in the token
function naiveVerify(token) {
const parts = token.split(".");
const { alg } = JSON.parse(decode(parts[0]));
if (alg === "HS256") {
if (sign(parts[0] + "." + parts[1]) !== parts[2])
return "✗ signature";
}
// "none": the token itself says it has no signature
return "✓ " + JSON.parse(decode(parts[1])).sub;
}

It reads alg from the header and checks the signature only if it says HS256 there. The standard really has an algorithm none: "a token without a signature". Here are four tokens: the genuine one, the same with a changed payload, a hand-written token with alg: none, sub: admin and an empty signature, and a genuine but expired token:

const now = 1_793_000_000;
const good = issue("maria", now + 900);
const p = good.split(".");
const admin = encode({ sub: "admin", exp: now + 900 });
// a changed payload with the old signature
const changed = p[0] + "." + admin + "." + p[2];
// alg: none and no signature at all
const none = encode({ alg: "none", typ: "JWT" }) + "." + admin + ".";
const old = issue("maria", now - 60);
console.log("genuine:", naiveVerify(good));
console.log("changed payload:", naiveVerify(changed));
console.log("alg: none:", naiveVerify(none));
console.log("expired:", naiveVerify(old));
Output
genuine: ✓ maria
changed payload: ✗ signature
alg: none: ✓ admin
expired: ✓ maria

A changed payload is caught, because the signature no longer matches. But alg: none lets admin in without any signature, and the expired token passes, because exp is never looked at. The header is user input, like everything in the token until the signature is checked (the lesson "Untrusted input"). A check that asks the token how to check it trusts the attacker.

See it

The fix: three checks in order

function verify(token, now) {
const parts = token.split(".");
if (parts.length !== 3) return "✗ format";
// the algorithm is pinned: never taken from the token
const { alg } = JSON.parse(decode(parts[0]));
if (alg !== "HS256") return "✗ algorithm";
const data = parts[0] + "." + parts[1];
if (!same(sign(data), parts[2])) return "✗ signature";
const claims = JSON.parse(decode(parts[1]));
if (typeof claims.exp !== "number" || now >= claims.exp)
return "✗ expired";
return "✓ " + claims.sub;
}
function same(a, b) {
// compares all bytes, without stopping at the first difference
const x = Buffer.from(a);
const y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
}
  1. The algorithm is pinned. The server knows it signs with HS256 and accepts nothing else. none stops here.
  2. The signature is compared in constant time, like the CSRF token in the previous lesson.
  3. exp is required. A token without exp or with a past exp is rejected.

The same four tokens:

const now = 1_793_000_000;
const good = issue("maria", now + 900);
const p = good.split(".");
const admin = encode({ sub: "admin", exp: now + 900 });
// a changed payload with the old signature
const changed = p[0] + "." + admin + "." + p[2];
// alg: none and no signature at all
const none = encode({ alg: "none", typ: "JWT" }) + "." + admin + ".";
const old = issue("maria", now - 60);
console.log("genuine:", verify(good, now));
console.log("changed payload:", verify(changed, now));
console.log("alg: none:", verify(none, now));
console.log("expired:", verify(old, now));
Output
genuine: ✓ maria
changed payload: ✗ signature
alg: none: ✗ algorithm
expired: ✗ expired

Try it

Fill in the blank

Which algorithm does the check accept?

const { alg } = JSON.parse(decode(parts[0]));
if (alg !== "") return "✗ algorithm";

Try it

Find the bug

This check pins HS256 and compares the signature correctly. But an old version of the app issued tokens without exp, and they still work today. Which line has the bug?

Pick the line with the bug.

The idea

Trap 2: where the token lives and how it is revoked

localStorage. Web apps often keep a JWT in localStorage, because it is handy. But every script on the page reads localStorage. One XSS hole and the foreign script sends the token to the attacker, who uses it until it expires. The signature does not help: the token is not forged, it is stolen whole. In the browser the safer place is a cookie with HttpOnly, but then it goes by itself and you need the CSRF defence from the previous lesson.

Revoking. The server checks only the signature and exp. If Maria's phone is stolen, her token works until exp, even if she changes her password. So access tokens are short (5 to 15 minutes), and a long login uses a refresh token that the server keeps and can delete. Another way is a list of revoked tokens on the server, but then the server keeps state again, as with a session.

When a session is simpler. If you have one website with one server, the session cookie from the previous lesson is the simpler answer: you revoke it by deleting the row, there is no signature to check and no algorithm to get wrong. JWT makes sense when many services check the token without a shared session table, or between different companies (login with Google is OpenID Connect, and that is JWT).

More precisely, if you are curious

The libraries. In a real project you do not write JWT by hand: Java has jjwt, java-jwt (Auth0) and Nimbus JOSE + JWT; C# has Microsoft.IdentityModel.JsonWebTokens, and ASP.NET Core checks tokens with AddJwtBearer; Python has PyJWT; Node has jsonwebtoken and jose. With a library too, the algorithm is pinned explicitly: jwt.decode(token, key, algorithms=["HS256"]) in PyJWT, jwt.verify(token, key, { algorithms: ["HS256"] }) in jsonwebtoken, ValidAlgorithms in TokenValidationParameters, verifyWith(key) in jjwt. Old versions of some libraries accepted none when you did not tell them the algorithm.

RS256 and ES256. With these a private key signs and a public key checks: that way many services can check without being able to sign. Then there is another trap, algorithm confusion: a server that expects RS256 but asks the token may accept HS256 with the public key as the HMAC key. The fix is the same: a pinned algorithm.

Other fields. iat (when it was issued), nbf (not before), aud (which service it is for), iss (who issued it), jti (a unique id, for revoking). Server clocks drift apart, so the libraries allow a few seconds of leeway on exp.

An encrypted JWT exists (JWE), but it is rarely needed: it is simpler not to put secrets in the token.

Exercise

Log out on all devices

Solve it in your own editor.

Maria loses her phone and taps "Log out on all devices". Add jti to the payload, a random unique id of the token. Write logout, which records the jti of the token in a set of revoked ones, and a check that after verify rejects a revoked token with "✗ revoked". A new login gives a new token that works.

Output
before logout: ✓ maria
after logout: ✗ revoked
new login: ✓ maria

More tasks

  • A short life. Make the access token 5 minutes and add a refresh token kept on the server in a table. What do you have to delete on "Log out on all devices" now?
  • Cleaning up. A revoked jti only needs to be kept until the token would expire anyway. Keep exp too and remove the old entries.

Test

Check yourself

1. Who can read the payload of a JWT?
2. Why does the check pin the algorithm (only HS256) instead of reading it from the header?
3. A web app keeps the JWT in localStorage. What happens if the page has an XSS hole?
4. Maria's phone is stolen, and the app token is valid for 30 days. What can the shop do?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·