Wenn JWT-Authentifizierung zustandsbehaftet wird: Ein Argument für Server-seitige Sessions in Node
Blocklisten und Cache-Strukturen machen die JWT-Authentifizierung ohnehin zustandsbehaftet. Express-Sessions in Node.js mit Postgres-Unterstützung sind einfacher und eignen sich weiterhin gut für JWTs.
Quellen: Forget JSON Web Tokens: Server-Side Sessions Cut Our Auth Bugs By 70%
Die stateless JWT-Authentifizierung sieht auf einem Whiteboard elegant aus, doch viele Teams stellen fest, dass das Ausloggen, das erzwungene Abmelden sowie das Zurücksetzen von Passwörtern heimlich wieder einen Zustand einführen. Sobald eine Blockliste für widerrufene Token, ein Speicher für Refresh-Tokens sowie benutzerdefiniertes Middleware zum Einsatz kommen, wird die Authentifizierungsschicht zum zerbrechlichsten Teil des Systems. Dieser Artikel geht auf dieses Versagenmuster ein, zeigt, wie eine mit Postgres unterstützte Sitzungseinrichtung in Express es ersetzt, und erläutert die seltenen Fälle, in denen JWTs weiterhin das bessere Werkzeug sind.
Ein bekanntes Versagenmuster
Stellen Sie sich ein Team vor, das stolz auf seine JWT-Einrichtung ist – bis die Warteschlange überläuft. Benutzer werden zufällig abgemeldet, Passwortänderungen wirken nicht mehr, und ein Kunde erstellt an einem einzigen Tag drei Tickets, in denen jeweils derselbe Widerspruch beschrieben wird: Er ist gleichzeitig eingeloggt und nicht eingeloggt. Die Ingenieure verbringen vier Stunden mit diesem einen Ticket, um seinen Token zu entschlüsseln und dessen Ablaufzeit zu überprüfen – doch alles scheint gültig zu sein.
Solche Fehler entstehen in der Regel an den Schnittstellen zwischen verschiedenen Mechanismen, die jeweils hinzugefügt wurden, um eine Einschränkung des vorherigen Systems zu beheben.
Warum „stateless“ selten wirklich stateless bleibt
Die Standardargumente für JWTs sind verlockend. Es gibt weder einen Session-Speicher noch eine Datenbankabfrage: Man legt die Benutzer-ID in den Payload, signiert ihn, gibt ihn an den Client weiter und lässt den Token selbst als Quelle der Wahrheit dienen.
Das Problem ist die Widerrufung. Ein unterschriebenes Token bleibt bis zu seinem Ablauf gültig, und der Server verfügt nicht über eine eingebaute Möglichkeit, es vorzeitig zu kündigen. Das Abmelden eines Benutzers überall, die Reaktion auf ein gestohlenes Token oder die Außer Kraft Setzung des Zugriffs nach einem Passwortwechsel erfordern alle eine Blockliste für Tokens – genau die Art von gemeinsamem Zustand, den das Design eigentlich beseitigen sollte.
In dem oben beschriebenen Szenario muss das Team schließlich eine Redis-Blockliste für widerrufene Tokens, einen separaten Speicher für Erneuerungstokens sowie eine benutzerdefinierte Middleware-Schicht einsetzen, die sie miteinander verbindet. Ein System, das ursprünglich auf Zustandslosigkeit ausgelegt war, ist nun zum zustandsreichsten Bestandteil der Architektur geworden.
Was die Prüfung pro Anfrage bewirkt
Authorization-Header, prüft ihn in der Redis-Blocklist und verifiziert anschließend die Signatur sowie die Angaben mitjwt.verify.
// verify access token
function verifyToken(req, res, next) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ error: "No token" });
// check if revoked
const revoked = await redis.get(`revoked:${token}`);
if (revoked) return res.status(401).json({ error: "Token revoked" });
jwt.verify(token, process.env.SECRET, (err, decoded) => {
if (err) return res.status(401).json({ error: "Invalid token" });
req.user = decoded;
next();
});
}
In der aktuellen Form würde die Funktion gar nicht ausgeführt werden, da sie await verwendet, ohne dass die Funktion als async deklariert ist. Das größere Problem liegt auf architektonischer Ebene: Jede Anfrage führt nun eine Netzwerkanfrage an Redis aus, wodurch der zentrale Vorteil von JWTs – die Vermeidung einer Abfrage – verloren geht. Zudem entstehen CPU-Kosten durch die Verifizierung des Tokens sowie eine zusätzliche Überprüfung der Gültigkeitsdauer und der Angaben.
Die Fehler verbergen sich in den Lücken zwischen diesen drei Überprüfungen:
- Ein Zugriffstoken läuft ab, und der Erneuerungsaufruf scheitert stillschweigend.
Jedes dieser Probleme erfordert eine Lösung in einer anderen Schicht, und eine Lösung in einer Schicht kann leicht eine Annahme in einer anderen Schicht zunichtemachen.
Drei Mechanismen durch einen einzigen Session-Speicher ersetzen
Die Alternative sind Server-seitige Sessions. Der Client enthält nur eine undurchsichtige Session-ID in einer Cookie-Datei, während die Session-Daten in der Datenbank gespeichert werden. In Express bewahrt express-session mit dem connect-pg-simple-Speicher die Sessions in Postgres auf:
const session = require("express-session");
const PgSession = require("connect-pg-simple")(session);
app.use(session({
store: new PgSession({ pool: db }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
}
}));
Mehrere Einstellungen übernehmen den größten Teil der Sicherheitsfunktionen. httpOnly schützt die Cookie vor Zugriffen durch JavaScript auf der Seite und verhindert so den Diebstahl von Tokens über XSS. secure stellt sicher, dass die Daten nur über HTTPS übertragen werden. resave: false vermeidet das Zurückschreiben unveränderter Sessions bei jeder Anfrage, und saveUninitialized: false hindert den Speicher daran, sich mit leeren Sessions für anonyme Besucher zu füllen. maxAge gibt den Sessions eine Gültigkeitsdauer von sieben Tagen. Beachten Sie, dass connect-pg-simple davon ausgeht, dass seine Sessions-Tabelle bereits existiert – erstellen Sie sie daher im Zuge Ihrer Migrationsarbeiten.
Die anschließenden Operationen sind dann fast trivial:
- Beim Anmelden wird
req.session.userIdgesetzt. - Beim Abmelden wird
req.session.destroy()aufgerufen. - Ein erzwungenes Abmelden löscht die Zeilen des Benutzers aus der Sessions-Tabelle.
Die Aufhebung erfolgt nun durch einen einzigen Löschvorgang, wobei es keine Ablaufdatum für die Blockliste gibt, das zu einer Asynchronie führen könnte, und jede Anfrage liest dieselbe autoritative Zeile. Zwei Sicherheitsmaßnahmen sind empfehlenswert: Rufen Sie bei der Anmeldung req.session.regenerate() auf, um eine Session-Fixierung zu verhindern, und setzen Sie sameSite auf die Cookie (zusätzlich zur notwendigen CSRF-Schutzmaßnahme), da Cookies vom Browser automatisch gesendet werden, während Träger-Header dies nicht tun.
In dem hier beschriebenen Szenario führte die Migration zum Löschen von etwa 340 Zeilen Authentifizierungslogik – jede gelöschte Zeile bedeutete einen weniger Ort, an dem sich ein Fehler verstecken konnte.
Die Veränderung messen
Um den Wechsel fair beurteilen zu können, verfolgte das Team jeden mit der Authentifizierung verbundenen Fehlerbericht über 90 Tage vor und 90 Tage nach der Migration. Ihre ermittelten Zahlen:
Before (JWT):
- Auth bugs reported: 43
- Avg debug time per bug: 2.4 hours
- Force-logout capability: not possible
After (Sessions):
- Auth bugs reported: 13
- Avg debug time per bug: 0.6 hours
- Force-logout time: < 1 second (delete row)
Reduction: ~70%
Die Anzahl der Fehlerberichte ist von 43 auf 13 gesunken, was einer Reduzierung von etwa 70 % entspricht. Die durchschnittliche Debugging-Zeit pro Fehler ist von 2,4 Stunden auf 0,6 Stunden gefallen. Das erzwungene Abmelden war von unmöglich auf eine Löschung einer Zeile in unter einer Sekunde übergegangen. Dies sind die Zahlen eines Teams, keine Referenzwerte.
Der übliche Einwand betrifft die Datenbankabfrage bei jeder Anfrage. Bei den meisten Anwendungen ist das Lesen der Primärschlüsselangaben aus einer Sessions-Tabelle im Vergleich zu den Abfragen, die bereits bei einer typischen Anfrage ausgeführt werden, unbedeutend teuer; daher liegt das eigentliche Engpass fast immer anderswo.
Vergleich der beiden Anfragepfade
Wenn man sie nebeneinander betrachtet, ist der Unterschied offensichtlich. Im JWT-Pfad muss der Server das Token entschlüsseln, bei Redis nach der Blocklist suchen und vor dem Ausführen des Route-Handlers Gültigkeitsdatum sowie Angaben überprüfen:
Client --> [Token in header] --> Server
|
Decode token (CPU)
|
Check Redis blocklist (network)
|
Validate expiry + claims
|
Route handler
Im Sessions-Pfad liest der Server die Session aus Postgres und übergibt sie anschließend an den Route-Handler:
Client --> [Session cookie] --> Server
|
Lookup session in Postgres
|
Route handler
Eine Abfrage bei einer einzigen Quelle der Wahrheit bedeutet, dass es genau einen Ort gibt, an dem man nachsehen muss, wenn etwas nicht funktioniert.
Wo JWTs weiterhin ihren Platz haben
Das macht JWTs keineswegs schlecht. Sie eignen sich hervorragend für kurzlebige, zweckbezogene Token: E-Mail-Verifizierungslinks, Passwort-Restore-Prozesse sowie andere eigenständige Nachweise einer Absicht, die innerhalb weniger Minuten ablaufen und niemals während ihrer Nutzung widerrufen werden müssen. Ein Verifizierungs-Token wie das unten gezeigte enthält die Benutzer-ID sowie deren Zweck und läuft nach 15 Minuten ab:
// email verification — expires fast, never needs revocation
function makeVerifyToken(userId) {
return jwt.sign({ userId, purpose: "verify" }, process.env.SECRET, {
expiresIn: "15m"
});
}
Die Angabe eines purpose-Werts ist wichtig, da sie verhindert, dass ein Verifizierungstoken an Stellen akzeptiert wird, an denen ein anderer Token-Typ erwartet wird. Bedenken Sie, dass ein JWT allein keine Einmalverwendung gewährleisten kann; wenn ein Reset-Link nur einmal funktionieren soll, muss festgehalten werden, dass er bereits verwendet wurde, oder der Token muss mit einem Wert verknüpft werden, der nach der Verwendung ändert, wie zum Beispiel das aktuelle Passworthash.
Die Spezifikation war nie das Problem. Das Problem lag darin, sie auf dauerhafte, widerrufbare, lang lebende Benutzersitzungen anzuwenden, wofür Sitzungen ursprünglich konzipiert waren.
Warum Teams an komplexen Authentifizierungsmethoden festhalten
Sitzungen wirken veraltet, während JWTs modern erscheinen, und es gibt weitaus mehr Tutorials zu JWTs als zu Sitzungen. Dieses Ungleichgewicht prägt, was als richtig angesehen wird.
Erfahrene Ingenieure neigen oft in die entgegengesetzte Richtung und wählen langweilige Lösungen. Genügend Produktionsvorfälle haben sie gelehrt, dass langweilige Systeme selten nachts jemanden benachrichtigen und dass Server-seitige Sessions seit Jahrzehnten zuverlässig funktionieren – lange bevor Redis existierte.
Die Wahl Ihres Ansatzes
- Starten Sie eine neue Webanwendung? Wählen Sie Server-seitige Sessions: ein
httpOnly,secure,sameSite-Cookie, Sessions werden in Postgres gespeichert, die Session wird beim Anmelden neu generiert und beim Abmelden gelöscht. Für einen umfassenderen Vergleich sehen Sie unseren Leitfaden zu der Wahl zwischen Sessions und JWTs. - Führen Sie eine sauber funktionierende JWT-Einrichtung? Lassen Sie sie unverändert; eine Migration nur um der Migration willen erhöht das Risiko.
Die beste ingenieurtechnische Entscheidung ist oft die, bei der Code entfernt wird. Weniger Zeilen bedeuten weniger Möglichkeiten für Fehler, und weniger Schichten erleichtern es, die verbleibenden Fehler schneller zu finden.