Ein JWT ist ein Format, kein Login-System
Letztens lief auf Hacker News mal wieder ein Thread über JWTs durch. Der immergleiche Streit: die einen schwören drauf und packen alles in JSON Web Tokens, die anderen sagen, man soll sie für Sessions gar nicht erst anfassen. Beide Lager argumentieren mit Inbrunst, beide haben Beispiele, und am Ende geht jeder unverändert nach Hause.
Das war für mich der Anlass, das Thema endlich mal für mich selbst zu erforschen. Ehrlich gesagt nicht, weil ich JWTs längst durchschaut hätte, sondern weil sie ständig auftauchen: Wenn ich mit Claude meine Projekte plane, kommt das Thema immer wieder auf, mal als naheliegende Option, mal mit dem Abwägen, ob es hier nicht das falsche Werkzeug ist. Ich wollte die Hintergründe verstehen, statt nur zuzustimmen.
Eine These löst den ganzen Streit auf: der wiederkehrende Konflikt entsteht im Kern aus einer einzigen Verwechslung. Die Leute streiten nicht wirklich über JWTs. Sie streiten über zwei verschiedene Dinge und nennen beide gleich. Wenn man die beiden auseinanderzieht, bleibt am Ende kaum noch Streit übrig, sondern eine ziemlich nüchterne Entscheidungstabelle.
Was ein JWT wirklich ist
Fangen wir mit dem an, was die meisten Diskussionen überspringen. Ein JWT ist kein Verschlüsselungsverfahren und kein Auth-System. Es ist ein Format. Genauer: ein Format für signierte JSON-Daten. Mehr nicht.
Ein JWT besteht aus drei Teilen, jeder einzeln Base64URL-kodiert, mit Punkten getrennt:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0Iiwicm9sZSI6ImFkbWluIn0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
HEADER . PAYLOAD . SIGNATURE
Wenn man die ersten beiden Teile dekodiert, kommt lesbares JSON heraus:
// Header
{ "alg": "HS256", "typ": "JWT" }
// Payload (die "Claims")
{ "sub": "1234", "role": "admin", "exp": 1718700000 }
Der Header sagt, mit welchem Verfahren signiert wurde. Der Payload enthält die eigentlichen Aussagen, im JWT-Jargon die Claims: wer der Nutzer ist, welche Rolle er hat, wann das Token abläuft. Hier liegt der erste Punkt, der oft falsch verstanden wird. Der Payload ist nicht verschlüsselt. Er ist nur kodiert. Jeder, der das Token in die Finger bekommt, kann den Inhalt lesen, und zwar ohne jeden Schlüssel. Base64 ist keine Verschlüsselung, sondern eine reine Umkodierung. Wer es selbst sehen will, fügt das Token oben auf jwt.io ein und sieht den Klartext sofort.
Was bringt dann die Signatur, der dritte Teil? Sie beweist genau zwei Dinge, und kein drittes. Erstens: das Token wurde seit der Ausstellung nicht verändert. Zweitens: es wurde mit dem Schlüssel des Ausstellers signiert, also von dem Server, der das Token ausgibt. Und genau darin liegt die Sicherheit: Dieser Schlüssel liegt nur beim Server, sonst hat ihn niemand. Wer ihn nicht besitzt, kann keine gültige Signatur erzeugen, egal wie er den Payload verändert. Ein JWT ist damit fälschungssicher, aber nicht geheim. Wer eine Zeile im Payload manipuliert, ohne den Schlüssel zu haben, bekommt eine ungültige Signatur und fliegt raus. Lesen kann den Payload aber jeder, der das Token sieht.
Damit das praktisch wird, hilft ein Bild. Ein klassisches Session-Cookie funktioniert wie eine Garderobenmarke. Du bekommst eine zufällige Nummer in die Hand, und die Information, wem diese Nummer gehört, liegt komplett beim Server. Reichst du die Marke ein, schlägt der Server in seiner Liste nach und weiß, wer du bist. Die Marke selbst sagt nichts aus, sie ist nur ein Zeiger.
Ein JWT funktioniert wie ein Ausweis, den man selbst bei sich trägt. Die Information steht direkt drin, fälschungssicher versiegelt. Jeder, der die Echtheitsmerkmale prüfen kann, also die Signatur verifiziert, weiß sofort, wer du bist und was du darfst, ohne irgendwo nachzuschlagen. Das ist der ganze Sinn der Konstruktion: stateless Verifikation. Kein Lookup, keine zentrale Liste, die man befragen muss. Das Token trägt seine eigene Wahrheit mit sich.
Der eine Use-Case, für den es gemacht ist
Wenn man diese Eigenschaft ernst nimmt, ergibt sich von selbst, wann ein JWT das richtige Werkzeug ist. Sinnvoll wird es genau dann, wenn die Partei, die das Token ausstellt, eine andere ist als die, die es prüft, und beide keine gemeinsame Datenbank teilen. Das ist die ganze Bedingung, und an ihr entscheidet sich fast alles.
Drei Beispiele machen das konkret:
- OAuth und OpenID Connect. Google stellt ein Token aus, das deine App entgegennimmt. Deine App prüft es mit Googles öffentlichem Schlüssel und weiß damit, dass der Login echt ist. Sie muss Google dafür bei keinem einzigen Request fragen. Aussteller und Prüfer sind verschiedene Organisationen, eine gemeinsame Datenbank existiert nicht. Genau hier glänzt das Format.
- Microservices. Ein zentraler Auth-Service stellt Tokens aus, der Bestell-Service und der Versand-Service prüfen sie nur noch. Keiner der nachgelagerten Dienste braucht Zugriff auf die Auth-Datenbank. Wird ein einzelner Sub-Service kompromittiert, bleibt der Schaden begrenzt, weil dort ohnehin keine Zugangsdaten liegen.
- Cloud-Infrastruktur. Dienste weisen sich gegenseitig mit kurzlebigen Tokens als die aus, die sie behaupten zu sein. Ein Identitätsnachweis, der nur ein paar Minuten gilt und nicht zentral nachgeschlagen werden muss.
In all diesen Fällen ist Statelessness ein echter Gewinn, kein abstrakter Vorteil aus einem Vortrag. Konkret gespart wird ein Netzwerk-Roundtrip zur Auth-Datenbank, und zwar pro Request. Wenn ein Prüfer Millionen Anfragen pro Minute verarbeitet und jede einzelne sonst eine Rückfrage an einen zentralen Dienst auslösen würde, ist das der Unterschied zwischen einem System, das skaliert, und einem, das an seiner eigenen Auth-Datenbank erstickt. Das Token mitzuschicken und lokal zu verifizieren ist hier nicht bequem, sondern die ganze Architektur.
Wo es gefährlich wird
Und jetzt zu der Stelle, um die sich der Streit eigentlich dreht. Das problematische Muster ist immer dasselbe: ein JWT als Session-Token für eingeloggte Browser-User, gespeichert im localStorage. Klassischer Single-Page-App-Login, das Token wandert ins localStorage, wird bei jedem Request als Authorization-Header mitgeschickt, und der Server verifiziert nur die Signatur. Klingt elegant. Ist es nicht.
Drei Probleme treffen hier zusammen:
- Nicht widerrufbar. Das ist das schwerste, deshalb ausführlich. Ein Session-Cookie widerrufst du, indem du einen Datenbankeintrag löschst. Eine Zeile, und die Session ist tot. Ein JWT widerrufst du nicht. Es ist gültig, bis es abläuft, und zwar egal was in der Zwischenzeit passiert. Der Nutzer hat sich ausgeloggt? Das Token gilt weiter. Ein Mitarbeiter wurde gekündigt? Sein Token gilt weiter. Jemand hat sein Passwort geändert, weil der Account gehackt wurde? Das gestohlene Token gilt weiter. Das Token ist versehentlich in einem öffentlichen GitHub-Repo gelandet? Gilt weiter, bis zum Ablauf, für jeden, der es findet. Die übliche Antwort darauf ist eine Sperrliste, eine Revocation-List, die bei jedem Request konsultiert wird. Klingt nach einer Lösung, ist aber das Gegenteil. In dem Moment, in dem du bei jedem Request eine zentrale Liste fragen musst, ob das Token noch gültig ist, hast du die Statelessness aufgegeben, für die du das JWT überhaupt genommen hast. Du machst pro Request wieder einen Lookup. Dann kannst du auch gleich ein Session-Cookie nehmen und dir den ganzen Token-Aufbau sparen.
- localStorage ist offen für XSS. Jedes JavaScript, das auf deiner Seite läuft, kann localStorage lesen. Jedes. Auch das, was du nie eingeladen hast: eingeschleuster Code über eine XSS-Lücke, eine kompromittierte npm-Dependency, ein Tracking-Skript, das mehr tut als versprochen. Ist das Token erst einmal abgegriffen, ist es offline für die gesamte Restlaufzeit wiederverwendbar. Ein
HttpOnly-Cookie kann JavaScript dagegen gar nicht erst lesen, das Flag ist genau dafür da. - Du löst Komplexität, die du nicht hast. Beim klassischen Web-Login stellt dieselbe App das Token aus und prüft es auch wieder. Aussteller und Prüfer sind identisch und teilen sich ohnehin dieselbe Datenbank. Damit existiert der eine Vorteil, für den JWTs gebaut sind, schlicht nicht. Du baust dir alle Stolperfallen ein und bekommst keinen einzigen Gegenwert dafür.
Zu diesen architektonischen Problemen kommen noch die Implementierungs-Fallen, die das Format mitbringt und die PortSwigger in seiner Web Security Academy ausführlich auseinandernimmt. Zwei davon tauchen immer wieder auf und lohnen einen genaueren Blick:
Die ehrliche Gegenseite
So einseitig, wie das bis hierher klingt, ist die Sache nicht. Die JWT-Befürworter haben Argumente, die ernst zu nehmen sind, und es wäre unredlich, sie wegzulassen. Drei davon halten einer Prüfung stand:
- Die Sperrliste ist kleiner, als sie klingt. Der Einwand gegen Revocation-Lists oben unterschlägt etwas: die Liste der gesperrten, aber noch nicht abgelaufenen Tokens ist in der Regel viel kürzer als die Liste aller aktiven Sessions. Eine kleine, append-only Sperrliste lässt sich über geografisch verteilte Server deutlich leichter replizieren als eine komplette Session-Datenbank. Der Punkt greift allerdings erst, wenn man tatsächlich verteilt arbeitet. Auf einem einzelnen Server ist das kein Vorteil.
- Logout überall lässt sich elegant lösen. Ein einzelnes Feld pro Nutzer, etwa
min_issued_at, das beim Abmelden aller Geräte auf den aktuellen Zeitpunkt gesetzt wird. Jedes Token mit einem ältereniatwird danach abgelehnt. Sauber, aber eben weiterhin ein Lookup pro Request, also State. Der Stateless-Charme schwindet, sobald man genauer hinsieht. - Robustheit gegen DDoS. Ein gefälschtes oder abgelaufenes JWT lässt sich allein durch die Signaturprüfung ablehnen, ganz ohne die Datenbank zu berühren. Eine Session-ID muss für dieselbe Ablehnung erst den Session-Store treffen. Unter Last ist das ein realer Unterschied: der teure Pfad wird bei JWTs gar nicht erst betreten.
Der ehrliche Konsens fällt damit unspektakulär aus. Für Browser-Sessions: nein. Für Service-zu-Service: ja. Das ist keine Glaubensfrage und keine Frage des Geschmacks, sondern eine Frage des Use-Case. Wer JWTs grundsätzlich verteufelt, hat den Service-Fall nicht im Blick. Wer sie überall hinsteckt, hat den Browser-Fall nicht durchdacht.
Was stattdessen, und wie aufwändig
Bleibt die praktische Frage: Wenn nicht JWT, was dann, und was kostet das?
Für den Web-Login: ein klassisches Session-Cookie. Der Ablauf ist seit Jahrzehnten derselbe und langweilig genau im richtigen Sinn:
- Beim Login eine kryptografisch zufällige ID erzeugen, aus einem CSPRNG, etwa 32 Byte.
- Die Zuordnung
session_idzu Nutzerdaten in einer Datenbank oder in Redis ablegen. - Die ID als Cookie ausliefern, mit den Flags
HttpOnly,SecureundSameSite. - Bei jedem Request die ID aus dem Cookie lesen, einmal nachschlagen, fertig.
Der Aufwand dafür ist minimal, denn praktisch jedes Framework liefert das fertig mit. Django, Rails, express-session, Spring, alle haben eine erprobte Implementierung an Bord. Man schreibt so gut wie keinen eigenen Krypto-Code, und genau das ist hier das Ziel. Eigener Krypto-Code an einer Auth-Stelle ist kein Feature, sondern ein Risiko. Logout ist eine Zeile: Eintrag löschen. Bleibt der eine Einwand, der gegen Session-Cookies immer kommt, der Datenbank-Lookup pro Request. Der ist in der Praxis ein indizierter Single-Row-Query, und unterhalb von Google-Größenordnungen ist das schlicht kein Problem. Die Sorge, das skaliere nicht, ist in den allermeisten Projekten verfrüht und löst ein Problem, das man nie haben wird.
Für Service-zu-Service und verteilte Systeme sind signierte Tokens dagegen die richtige Wahl, und dafür gibt es zwei vernünftige Wege.
Der erste Weg ist JWT, aber mit Disziplin. Nur asymmetrische Verfahren verwenden, also RS256, ES256 oder EdDSA. Niemals alg: none zulassen, niemals HMAC mit einem geteilten Secret. Die Lebensdauer kurz halten, fünf bis fünfzehn Minuten, und mit einem Refresh-Mechanismus kombinieren. Eine geprüfte, gepflegte Bibliothek nehmen statt selbst zu basteln. Und die Claims streng validieren: exp, aud und iss werden geprüft, nicht ignoriert. Wer diese Regeln einhält, umgeht die meisten der oben genannten Fallen.
Der zweite Weg ist ein anderes Format. PASETO tritt mit dem Anspruch an, die JWT-Fallen gar nicht erst zuzulassen:
So sympathisch das Konzept ist, ich würde PASETO nicht blind empfehlen. Für die meisten Projekte ist ein richtig konfiguriertes JWT mit einer auditierten Library die pragmatischere Wahl. Die Bibliotheken sind ausgereifter, die Integration in bestehende OAuth- und OIDC-Welten ist gegeben, und die Stolperfallen lassen sich mit der Disziplin von oben zuverlässig vermeiden. PASETO ist die bessere Wahl, wenn man bei null anfängt und die Freiheit hat, das Ökosystem mitzuwählen. In einem bestehenden Stack ist es oft mehr Reibung als Gewinn.
Fazit: der eigentliche Denkfehler
Damit zurück zur These vom Anfang. Es geht nicht um gut oder schlecht. Es geht um das richtige Werkzeug für den jeweiligen Job. Und der wiederkehrende Streit speist sich aus einer einzigen Verwechslung, die fast nie ausgesprochen wird.
Die Leute vergleichen Cookies mit JWTs. Genau das ist der Fehler, denn es ist ein Vergleich von Äpfeln mit Birnen. Ein Cookie ist wo du etwas speicherst, ein Speicher-Mechanismus im Browser. Ein JWT ist was du speicherst, ein Token-Format. Die beiden liegen auf verschiedenen Achsen. Du kannst ein JWT in ein Cookie legen, und das ist in vielen Fällen sogar die beste Variante. Speicher-Mechanismus und Token-Format sind zwei Fragen, nicht eine.
Sobald man die beiden Achsen trennt, fällt der ganze Streit in sich zusammen. Die eigentliche Unterscheidung ist nämlich eine ganz andere, und sie lässt sich in zwei Zeilen aufschreiben:
Session-Cookie: id → lookup(id) → user (State liegt beim Server)
JWT: token → verify(token, key) → claims (kein Lookup nötig)
Das ist der Kern. Liegt der State beim Server oder im Token? Das ist die Frage, über die zu reden sich lohnt. Cookie gegen JWT ist es nicht.
Wo nutzt du JWTs, und prüfst du sie bei jedem Request gegen eine Sperrliste? Falls ja, dann hast du im Grunde ein Session-Cookie gebaut, nur mit ein paar Extra-Schritten und mehr beweglichen Teilen. Das ist kein Vorwurf, manchmal ist man da historisch hineingewachsen. Aber es ist ein guter Moment, einmal innezuhalten und zu fragen, welche der beiden Achsen man eigentlich gerade bedient.
Weiter geht es in Schau in deinen Browser: welche Tokens dort wirklich liegen. Ein praktischer Blick darauf, was tatsächlich in deinem eigenen Browser liegt.