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

static String verify(String token, long now) {
String[] parts = token.split("\\.", -1);
if (parts.length != 3) return "✗ format";
// the algorithm is pinned: never taken from the token
String alg = text(decode(parts[0]), "alg");
if (!"HS256".equals(alg)) return "✗ algorithm";
String data = parts[0] + "." + parts[1];
if (!same(sign(data), parts[2])) return "✗ signature";
String payload = decode(parts[1]);
Long exp = number(payload, "exp");
if (exp == null || now >= exp) return "✗ expired";
return "✓ " + text(payload, "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").

// a demo key; the real one is 32 random bytes from a secret store
static final byte[] KEY = "demo-key-only-for-this-lesson".getBytes(UTF_8);
static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();
static String encode(String json) {
return B64.encodeToString(json.getBytes(UTF_8));
}
static String sign(String data) {
try {
Mac hmac = Mac.getInstance("HmacSHA256");
hmac.init(new SecretKeySpec(KEY, "HmacSHA256"));
return B64.encodeToString(hmac.doFinal(data.getBytes(UTF_8)));
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
static String issue(String user, long exp) {
String header = """
{"alg":"HS256","typ":"JWT"}""";
String payload = """
{"sub":"%s","exp":%d}""".formatted(user, exp);
String data = encode(header) + "." + encode(payload);
return data + "." + sign(data);
}
Java has no JSON in its standard library, so issue glues the two JSON texts together with a text block and formatted. That is safe only because user is an id from our own database, without quotes. A real project uses a library that also escapes.

A token for Maria, valid for 15 minutes:

long now = 1_793_000_000L;
String token = issue("maria", now + 900);
String[] parts = token.split("\\.");
System.out.println("header: " + parts[0]);
System.out.println("payload: " + parts[1]);
System.out.println("signature:" + parts[2]);
System.out.println("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?

String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYXJpYSIsImV4cCI6MTc5MzAwMDkwMH0.mXfW7XuQCRb8pXyoR6d687Dzn8zr5Y5uET-UFxEvOKw";
String payload = token.split("\\.")[1];
byte[] bytes = Base64.getUrlDecoder().decode(payload);
System.out.println(new String(bytes, UTF_8));
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:

static String decode(String part) {
return new String(Base64.getUrlDecoder().decode(part), UTF_8);
}
// Java has no JSON in its standard library: a regex is enough for two fields
static String text(String json, String field) {
Matcher m = Pattern.compile("\"" + field + "\":\"([^\"]*)\"").matcher(json);
return m.find() ? m.group(1) : null;
}
static Long number(String json, String field) {
Matcher m = Pattern.compile("\"" + field + "\":(\\d+)").matcher(json);
return m.find() ? Long.parseLong(m.group(1)) : null;
}
// NOT LIKE THIS: trusts the algorithm in the token
static String naiveVerify(String token) {
String[] parts = token.split("\\.", -1);
String alg = text(decode(parts[0]), "alg");
if ("HS256".equals(alg)) {
String data = parts[0] + "." + parts[1];
if (!sign(data).equals(parts[2]))
return "✗ signature";
}
// "none": the token itself says it has no signature
return "✓ " + text(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:

long now = 1_793_000_000L;
String good = issue("maria", now + 900);
String[] p = good.split("\\.");
String admin = """
{"sub":"admin","exp":%d}""".formatted(now + 900);
// a changed payload with the old signature
String changed = p[0] + "." + encode(admin) + "." + p[2];
// alg: none and no signature at all
String none = encode("""
{"alg":"none","typ":"JWT"}""") + "." + encode(admin) + ".";
String old = issue("maria", now - 60);
System.out.println("genuine: " + naiveVerify(good));
System.out.println("changed payload: " + naiveVerify(changed));
System.out.println("alg: none: " + naiveVerify(none));
System.out.println("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

static String verify(String token, long now) {
String[] parts = token.split("\\.", -1);
if (parts.length != 3) return "✗ format";
// the algorithm is pinned: never taken from the token
String alg = text(decode(parts[0]), "alg");
if (!"HS256".equals(alg)) return "✗ algorithm";
String data = parts[0] + "." + parts[1];
if (!same(sign(data), parts[2])) return "✗ signature";
String payload = decode(parts[1]);
Long exp = number(payload, "exp");
if (exp == null || now >= exp) return "✗ expired";
return "✓ " + text(payload, "sub");
}
static boolean same(String a, String b) {
// compares all bytes, without stopping at the first difference
return MessageDigest.isEqual(a.getBytes(UTF_8), b.getBytes(UTF_8));
}
  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:

long now = 1_793_000_000L;
String good = issue("maria", now + 900);
String[] p = good.split("\\.");
String admin = """
{"sub":"admin","exp":%d}""".formatted(now + 900);
// a changed payload with the old signature
String changed = p[0] + "." + encode(admin) + "." + p[2];
// alg: none and no signature at all
String none = encode("""
{"alg":"none","typ":"JWT"}""") + "." + encode(admin) + ".";
String old = issue("maria", now - 60);
System.out.println("genuine: " + verify(good, now));
System.out.println("changed payload: " + verify(changed, now));
System.out.println("alg: none: " + verify(none, now));
System.out.println("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?

String alg = text(decode(parts[0]), "alg");
if (!"".equals(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.

In the lesson Java reads JSON with a regular expression, because the standard library has no JSON. That is enough for our two fields, but it is not a parser: a real library (Jackson inside jjwt) reads JSON properly.

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 ·