JWT und seine Fallen
Kurz gesagt: Ein JWT ist ein signiertes Token aus Header, Payload und Signatur; der Payload ist nicht verschlüsselt und jeder kann ihn lesen, und der Server muss Algorithmus, Signatur und exp selbst prüfen.
Problem
Das Token, das Admin wurde
Die mobile App des Shops benutzt keine Seiten, sondern eine API. Das Team wählt JWT, „damit der Server keine Sitzungen speichern muss“, und kopiert die Token-Prüfung aus einem alten Blogpost. Monate später schreibt ein Sicherheitsforscher an den Shop: Mit einem Token, das er von Hand geschrieben hat, ist er als admin in die Erstattungs-API gekommen.
Der Schlüssel des Shops ist nicht geleakt. Das Admin-Passwort auch nicht. Der Forscher hat nichts gebrochen: Die Prüfung hat sein Token von selbst akzeptiert.
Die Idee
Ein signierter Ausweis
Die Sitzung aus der vorigen Lektion ist eine Garderobenmarke: Der Server führt eine Tabelle, wer hinter welcher Id steht. Ein JWT (JSON Web Token) ist ein anderer Ansatz: Das Token selbst sagt, wer du bist und bis wann es gültig ist, und der Server signiert es, damit es nicht geändert werden kann.
Das Token hat drei Teile, getrennt durch Punkte:
- header: wie es signiert ist, zum Beispiel
{"alg":"HS256","typ":"JWT"}; - payload: was es sagt, zum Beispiel
{"sub":"maria","exp":1793000900}.subist der Nutzer,expder Moment des Ablaufs, in Sekunden seit dem 1. Januar 1970; - signature: HMAC-SHA256 über "header.payload" mit einem geheimen Schlüssel des Servers.
Header und Payload sind JSON, geschrieben in base64url: base64 mit - und _ statt + und / und ohne = am Ende, damit es in eine Adresse und einen Header passt. Base64url ist eine Schreibweise, keine Verschlüsselung. Das Token ist signiert, nicht geheim: Jeder liest es, aber ohne den Schlüssel kann es niemand ändern, ohne dass die Signatur nicht mehr passt.
Sieh es dir an
Die drei Teile und das gefälschte Token
function verify(token, now) { const parts = token.split("."); if (parts.length !== 3) return "✗ Format"; // der Algorithmus ist festgelegt: nie aus dem Token genommen const { alg } = JSON.parse(decode(parts[0])); if (alg !== "HS256") return "✗ Algorithmus"; const data = parts[0] + "." + parts[1]; if (!same(sign(data), parts[2])) return "✗ Signatur"; const claims = JSON.parse(decode(parts[1])); if (typeof claims.exp !== "number" || now >= claims.exp) return "✗ abgelaufen"; return "✓ " + claims.sub;}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);}// SO NICHT: vertraut dem Algorithmus im Tokenfunction 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 "✗ Signatur"; } // "none": Das Token selbst sagt, dass es keine Signatur hat return "✓ " + JSON.parse(decode(parts[1])).sub;}function verify(token, now) { const parts = token.split("."); if (parts.length !== 3) return "✗ Format"; // der Algorithmus ist festgelegt: nie aus dem Token genommen const { alg } = JSON.parse(decode(parts[0])); if (alg !== "HS256") return "✗ Algorithmus"; const data = parts[0] + "." + parts[1]; if (!same(sign(data), parts[2])) return "✗ Signatur"; const claims = JSON.parse(decode(parts[1])); if (typeof claims.exp !== "number" || now >= claims.exp) return "✗ abgelaufen"; return "✓ " + claims.sub;}Schritt für Schritt, in Worten
- Der Header sagt, wie das Token signiert ist: HS256. Das JSON wird zu base64url-Text.
- Der Payload sagt, wer und bis wann: sub maria, exp in 15 Minuten. Auch base64url.
- Die Signatur ist HMAC-SHA256 über "header.payload" mit dem Schlüssel des Servers. Ohne den Schlüssel kann sie niemand erzeugen.
- Das Token sind die drei Teile mit Punkten. Die mobile App schickt es bei jeder Anfrage mit.
- Jeder, der das Token hat, liest den Payload ohne Schlüssel: base64url ist keine Verschlüsselung. Signiert, nicht geheim.
- Jetzt ein gefälschtes Token: Der Header sagt alg none, der Payload sagt sub admin, und es gibt keine Signatur.
- Die naive Prüfung fragt das Token, welchen Algorithmus sie benutzen soll. "none" überspringt die Signatur, und admin kommt herein.
- Die Lösung legt den Algorithmus fest: nur HS256, vom Server bestimmt. alg none wird vor allem anderen abgelehnt.
- Ein echtes, aber altes Token: Die Signatur stimmt, aber exp ist vorbei. Die naive Prüfung ließ es herein; diese nicht.
- Drei Prüfungen in dieser Reihenfolge: der vom Server gewählte Algorithmus, die Signatur, der Ablauf. Der Payload ist nie ein Geheimnis.
Signatur und base64url stecken in der Standardbibliothek jeder Sprache. Der Schlüssel hier ist ein Demo-Schlüssel: Der echte besteht aus 32 Zufallsbytes außerhalb des Codes (die Lektion „Geheimnisse im Code und in Git“).
const KEY = "demo-key-only-for-this-lesson"; // ein Demo-Schlüssel; der echte besteht aus 32 Zufallsbytes aus einem 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 schreibt ohne Leerzeichen, und Buffer und createHmac aus node:crypto kennen base64url ("base64url").Ein Token für Maria, 15 Minuten gültig:
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("Signatur:" + parts[2]);console.log("Payload ohne den Schlüssel:", decode(parts[1]));header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9payload: eyJzdWIiOiJtYXJpYSIsImV4cCI6MTc5MzAwMDkwMH0Signatur:mXfW7XuQCRb8pXyoR6d687Dzn8zr5Y5uET-UFxEvOKwPayload ohne den Schlüssel: {"sub":"maria","exp":1793000900}Der Schlüssel und „jetzt“ sind fest, deshalb ist das Token bei jedem Lauf und in allen vier Sprachen dasselbe. Die letzte Zeile ist der Payload, gelesen ohne den Schlüssel.
Probier es aus
Sage die Ausgabe voraus
Jemand hat Marias Token abgefangen. Es gibt keinen Schlüssel. Was gibt das aus?
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYXJpYSIsImV4cCI6MTc5MzAwMDkwMH0.mXfW7XuQCRb8pXyoR6d687Dzn8zr5Y5uET-UFxEvOKw";const payload = token.split(".")[1];console.log(Buffer.from(payload, "base64url").toString());Der mittlere Teil ist nur base64url. Er lässt sich ohne Schlüssel dekodieren und ergibt das ganze JSON, mit exp. Deshalb kommen nie Geheimnisse in den Payload.
Die Idee
Falle 1: Der Algorithmus kommt aus dem Token
Hier ist die Prüfung aus dem Blogpost. Um Header und Payload zu lesen, nutzt sie zwei Hilfsfunktionen:
const decode = (part) => Buffer.from(part, "base64url").toString();// SO NICHT: vertraut dem Algorithmus im Tokenfunction 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 "✗ Signatur"; } // "none": Das Token selbst sagt, dass es keine Signatur hat return "✓ " + JSON.parse(decode(parts[1])).sub;}Sie liest alg aus dem Header und prüft die Signatur nur, wenn dort HS256 steht. Der Standard kennt wirklich einen Algorithmus none: „ein Token ohne Signatur“. Hier sind vier Tokens: das echte, dasselbe mit geändertem Payload, ein handgeschriebenes Token mit alg: none, sub: admin und leerer Signatur, und ein echtes, aber abgelaufenes 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 });// ein geänderter Payload mit der alten Signaturconst changed = p[0] + "." + admin + "." + p[2];// alg: none und überhaupt keine Signaturconst none = encode({ alg: "none", typ: "JWT" }) + "." + admin + ".";const old = issue("maria", now - 60);console.log("echt:", naiveVerify(good));console.log("geänderter Payload:", naiveVerify(changed));console.log("alg: none:", naiveVerify(none));console.log("abgelaufen:", naiveVerify(old));echt: ✓ mariageänderter Payload: ✗ Signaturalg: none: ✓ adminabgelaufen: ✓ mariaEin geänderter Payload wird erwischt, weil die Signatur nicht mehr passt. Aber alg: none lässt admin ohne jede Signatur herein, und das abgelaufene Token geht durch, weil exp nie angesehen wird. Der Header ist Nutzereingabe, wie alles im Token, bis die Signatur geprüft ist (die Lektion „Nicht vertrauenswürdige Eingaben“). Eine Prüfung, die das Token fragt, wie sie es prüfen soll, vertraut dem Angreifer.
Sieh es dir an
Die Lösung: drei Prüfungen der Reihe nach
function verify(token, now) { const parts = token.split("."); if (parts.length !== 3) return "✗ Format"; // der Algorithmus ist festgelegt: nie aus dem Token genommen const { alg } = JSON.parse(decode(parts[0])); if (alg !== "HS256") return "✗ Algorithmus"; const data = parts[0] + "." + parts[1]; if (!same(sign(data), parts[2])) return "✗ Signatur"; const claims = JSON.parse(decode(parts[1])); if (typeof claims.exp !== "number" || now >= claims.exp) return "✗ abgelaufen"; return "✓ " + claims.sub;}function same(a, b) { // vergleicht alle Bytes, ohne beim ersten Unterschied anzuhalten const x = Buffer.from(a); const y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y);}- Der Algorithmus ist festgelegt. Der Server weiß, dass er mit HS256 signiert, und akzeptiert nichts anderes.
nonehört hier auf. - Die Signatur wird in konstanter Zeit verglichen, wie das CSRF-Token in der vorigen Lektion.
expist Pflicht. Ein Token ohneexpoder mit einemexpin der Vergangenheit wird abgelehnt.
Dieselben vier 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 });// ein geänderter Payload mit der alten Signaturconst changed = p[0] + "." + admin + "." + p[2];// alg: none und überhaupt keine Signaturconst none = encode({ alg: "none", typ: "JWT" }) + "." + admin + ".";const old = issue("maria", now - 60);console.log("echt:", verify(good, now));console.log("geänderter Payload:", verify(changed, now));console.log("alg: none:", verify(none, now));console.log("abgelaufen:", verify(old, now));echt: ✓ mariageänderter Payload: ✗ Signaturalg: none: ✗ Algorithmusabgelaufen: ✗ abgelaufenProbier es aus
Ergänze die Lücke
Welchen Algorithmus akzeptiert die Prüfung?
const { alg } = JSON.parse(decode(parts[0]));
if (alg !== "") return "✗ Algorithmus";HS256: der Algorithmus, mit dem der Server signiert. Er steht im Code des Servers, nicht im Token.
Probier es aus
Finde den Fehler
Diese Prüfung legt HS256 fest und vergleicht die Signatur richtig. Aber eine alte Version der App hat Tokens ohne exp ausgestellt, und sie funktionieren bis heute. In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
Der Ablauf wird nur geprüft, wenn exp da ist. Ein Token ohne exp geht durch und ist für immer gültig. Ein fehlender Ablauf muss eine Ablehnung sein: Eine Sicherheitsprüfung lehnt im Zweifel ab.
Die Idee
Falle 2: Wo das Token lebt und wie es widerrufen wird
localStorage. Web-Apps speichern ein JWT oft in localStorage, weil es bequem ist. Aber jedes Skript auf der Seite liest localStorage. Eine XSS-Lücke, und das fremde Skript schickt das Token an den Angreifer, der es benutzt, bis es abläuft. Die Signatur hilft nicht: Das Token ist nicht gefälscht, es ist ganz gestohlen. Im Browser ist ein Cookie mit HttpOnly der sicherere Ort, aber dann geht es von selbst mit, und du brauchst den CSRF-Schutz aus der vorigen Lektion.
Widerrufen. Der Server prüft nur die Signatur und exp. Wird Marias Handy gestohlen, funktioniert ihr Token bis exp, auch wenn sie ihr Passwort ändert. Deshalb sind Access-Tokens kurz (5 bis 15 Minuten), und eine lange Anmeldung nutzt ein Refresh-Token, das der Server speichert und löschen kann. Eine andere Möglichkeit ist eine Liste widerrufener Tokens auf dem Server, aber dann speichert der Server wieder Zustand, wie bei einer Sitzung.
Wann eine Sitzung einfacher ist. Hast du eine Website mit einem Server, ist das Sitzungs-Cookie aus der vorigen Lektion die einfachere Antwort: Du widerrufst es, indem du die Zeile löschst, es gibt keine Signatur zu prüfen und keinen Algorithmus, den man falsch wählen kann. JWT ergibt Sinn, wenn viele Dienste das Token ohne gemeinsame Sitzungstabelle prüfen oder zwischen verschiedenen Firmen (die Anmeldung mit Google ist OpenID Connect, und das ist JWT).
Genauer, für Neugierige
Die Bibliotheken. In einem echten Projekt schreibst du JWT nicht von Hand: Java hat jjwt, java-jwt (Auth0) und Nimbus JOSE + JWT; C# hat Microsoft.IdentityModel.JsonWebTokens, und ASP.NET Core prüft Tokens mit AddJwtBearer; Python hat PyJWT; Node hat jsonwebtoken und jose. Auch mit einer Bibliothek legst du den Algorithmus ausdrücklich fest: jwt.decode(token, key, algorithms=["HS256"]) in PyJWT, jwt.verify(token, key, { algorithms: ["HS256"] }) in jsonwebtoken, ValidAlgorithms in TokenValidationParameters, verifyWith(key) in jjwt. Alte Versionen mancher Bibliotheken akzeptierten none, wenn man ihnen den Algorithmus nicht sagte.
RS256 und ES256. Hier signiert ein privater Schlüssel und ein öffentlicher prüft: So können viele Dienste prüfen, ohne signieren zu können. Dann gibt es eine weitere Falle, die Algorithm Confusion: Ein Server, der RS256 erwartet, aber das Token fragt, akzeptiert vielleicht HS256 mit dem öffentlichen Schlüssel als HMAC-Schlüssel. Die Lösung ist dieselbe: ein festgelegter Algorithmus.
Weitere Felder. iat (wann ausgestellt), nbf (nicht vor), aud (für welchen Dienst), iss (wer es ausgestellt hat), jti (eine eindeutige Id, zum Widerrufen). Die Uhren der Server laufen auseinander, deshalb erlauben die Bibliotheken bei exp einige Sekunden Spielraum.
Ein verschlüsseltes JWT gibt es (JWE), aber es wird selten gebraucht: Es ist einfacher, keine Geheimnisse ins Token zu legen.
Übung
Auf allen Geräten abmelden
Löse die Aufgabe in deinem eigenen Editor.
Maria verliert ihr Handy und tippt auf „Auf allen Geräten abmelden“. Füge dem Payload jti hinzu, eine zufällige eindeutige Id des Tokens. Schreibe logout, das die jti des Tokens in einer Menge widerrufener ablegt, und eine Prüfung, die nach verify ein widerrufenes Token mit „✗ widerrufen“ ablehnt. Eine neue Anmeldung ergibt ein neues Token, das funktioniert.
vor dem Abmelden: ✓ marianach dem Abmelden: ✗ widerrufenneue Anmeldung: ✓ mariaWeitere Aufgaben
- Ein kurzes Leben. Mach das Access-Token 5 Minuten lang und füge ein Refresh-Token hinzu, das der Server in einer Tabelle speichert. Was musst du jetzt bei „Auf allen Geräten abmelden“ löschen?
- Aufräumen. Eine widerrufene
jtimuss nur so lange gespeichert werden, bis das Token ohnehin ablaufen würde. Speichere auchexpund entferne die alten Einträge.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Netzwerkprogrammierung · Nicht vertrauenswürdige Eingaben · Passwörter · Sitzungen, Cookies und CSRF
- Geht weiter in: Kryptografie, die du nicht selbst schreibst · Skalierung
- Aufgaben zum Thema: Sicherheit für Entwickler