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}.subis the user,expis 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
def verify(token, now): parts = token.split(".") if len(parts) != 3: return "✗ format" # the algorithm is pinned: never taken from the token if json.loads(decode(parts[0])).get("alg") != "HS256": return "✗ algorithm" mac = sign(parts[0] + "." + parts[1]).encode() if not hmac.compare_digest(mac, parts[2].encode()): return "✗ signature" claims = json.loads(decode(parts[1])) exp = claims.get("exp") if not isinstance(exp, int) or now >= exp: return "✗ expired" return "✓ " + claims["sub"]def issue(user, exp): header = {"alg": "HS256", "typ": "JWT"} payload = {"sub": user, "exp": exp} data = encode(header) + "." + encode(payload) return data + "." + sign(data)# NOT LIKE THIS: trusts the algorithm in the tokendef naive_verify(token): parts = token.split(".") alg = json.loads(decode(parts[0]))["alg"] if alg == "HS256": if sign(parts[0] + "." + parts[1]) != parts[2]: return "✗ signature" # "none": the token itself says it has no signature return "✓ " + json.loads(decode(parts[1]))["sub"]def verify(token, now): parts = token.split(".") if len(parts) != 3: return "✗ format" # the algorithm is pinned: never taken from the token if json.loads(decode(parts[0])).get("alg") != "HS256": return "✗ algorithm" mac = sign(parts[0] + "." + parts[1]).encode() if not hmac.compare_digest(mac, parts[2].encode()): return "✗ signature" claims = json.loads(decode(parts[1])) exp = claims.get("exp") if not isinstance(exp, int) or now >= exp: return "✗ expired" return "✓ " + claims["sub"]Step by step, in words
- The header says how the token is signed: HS256. The JSON becomes base64url text.
- The payload says who and until when: sub maria, exp in 15 minutes. Also base64url.
- The signature is HMAC-SHA256 over "header.payload" with the key of the server. Without the key nobody can make it.
- The token is the three parts with dots. The mobile app sends it with every request.
- Anyone who has the token reads the payload without a key: base64url is not encryption. Signed, not secret.
- Now a forged token: the header says alg none, the payload says sub admin, and there is no signature.
- The naive check asks the token which algorithm to use. "none" skips the signature, and admin gets in.
- The fix pins the algorithm: only HS256, decided by the server. alg none is rejected before anything else.
- A genuine but old token: the signature is right, but exp has passed. The naive check let it in; this one does not.
- 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").
KEY = b"demo-key-only-for-this-lesson" # a demo key; the real one is 32 random bytes from a secret store
def b64(data): return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def encode(obj): return b64(json.dumps(obj, separators=(",", ":")).encode())
def sign(data): return b64(hmac.new(KEY, data.encode(), hashlib.sha256).digest())def issue(user, exp): header = {"alg": "HS256", "typ": "JWT"} payload = {"sub": user, "exp": exp} data = encode(header) + "." + encode(payload) return data + "." + sign(data)json.dumps(…, separators=(",", ":")) writes JSON without spaces, like the other languages. base64.urlsafe_b64encode adds = at the end, so rstrip(b"=") removes them.A token for Maria, valid for 15 minutes:
now = 1_793_000_000token = issue("maria", now + 900)parts = token.split(".")print("header: " + parts[0])print("payload: " + parts[1])print("signature:" + parts[2])print("payload without the key:", decode(parts[1]))header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9payload: eyJzdWIiOiJtYXJpYSIsImV4cCI6MTc5MzAwMDkwMH0signature:mXfW7XuQCRb8pXyoR6d687Dzn8zr5Y5uET-UFxEvOKwpayload 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?
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYXJpYSIsImV4cCI6MTc5MzAwMDkwMH0.mXfW7XuQCRb8pXyoR6d687Dzn8zr5Y5uET-UFxEvOKw"payload = token.split(".")[1]print(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)).decode())The middle part is just base64url. It decodes without a key and gives the whole JSON, with exp. That is why secrets never go in the payload.
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:
def decode(part): return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4)).decode()# NOT LIKE THIS: trusts the algorithm in the tokendef naive_verify(token): parts = token.split(".") alg = json.loads(decode(parts[0]))["alg"] if alg == "HS256": if sign(parts[0] + "." + parts[1]) != parts[2]: return "✗ signature" # "none": the token itself says it has no signature return "✓ " + json.loads(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:
now = 1_793_000_000good = issue("maria", now + 900)p = good.split(".")admin = encode({"sub": "admin", "exp": now + 900})# a changed payload with the old signaturechanged = p[0] + "." + admin + "." + p[2]# alg: none and no signature at allnone = encode({"alg": "none", "typ": "JWT"}) + "." + admin + "."old = issue("maria", now - 60)print("genuine:", naive_verify(good))print("changed payload:", naive_verify(changed))print("alg: none:", naive_verify(none))print("expired:", naive_verify(old))genuine: ✓ mariachanged payload: ✗ signaturealg: none: ✓ adminexpired: ✓ mariaA 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
def verify(token, now): parts = token.split(".") if len(parts) != 3: return "✗ format" # the algorithm is pinned: never taken from the token if json.loads(decode(parts[0])).get("alg") != "HS256": return "✗ algorithm" mac = sign(parts[0] + "." + parts[1]).encode() if not hmac.compare_digest(mac, parts[2].encode()): return "✗ signature" claims = json.loads(decode(parts[1])) exp = claims.get("exp") if not isinstance(exp, int) or now >= exp: return "✗ expired" return "✓ " + claims["sub"]- The algorithm is pinned. The server knows it signs with HS256 and accepts nothing else.
nonestops here. - The signature is compared in constant time, like the CSRF token in the previous lesson.
expis required. A token withoutexpor with a pastexpis rejected.
The same four tokens:
now = 1_793_000_000good = issue("maria", now + 900)p = good.split(".")admin = encode({"sub": "admin", "exp": now + 900})# a changed payload with the old signaturechanged = p[0] + "." + admin + "." + p[2]# alg: none and no signature at allnone = encode({"alg": "none", "typ": "JWT"}) + "." + admin + "."old = issue("maria", now - 60)print("genuine:", verify(good, now))print("changed payload:", verify(changed, now))print("alg: none:", verify(none, now))print("expired:", verify(old, now))genuine: ✓ mariachanged payload: ✗ signaturealg: none: ✗ algorithmexpired: ✗ expiredTry it
Fill in the blank
Which algorithm does the check accept?
if json.loads(decode(parts[0])).get("alg") != "":
return "✗ algorithm"HS256: the algorithm the server signs with. It is in the server code, not in the token.
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 expiry is checked only if exp is there. A token without exp passes and is valid forever. A missing expiry must be a rejection: a security check refuses when in doubt.
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.
before logout: ✓ mariaafter logout: ✗ revokednew login: ✓ mariaMore 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
jtionly needs to be kept until the token would expire anyway. Keepexptoo and remove the old entries.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Network programming · Untrusted input · Passwords · Sessions, cookies and CSRF
- Continues in: Cryptography you don't write yourself · Scaling
- Tasks on this topic: Security for developers