Ein praktischer Leitfaden zur JWT-Authentifizierung
JSON Web Tokens werden in vielen neuen Backends standardmäßig verwendet, oft ohne dass jemand prüft, ob ein Token tatsächlich das Richtige ist oder was passiert, wenn man eines widerrufen muss. Hier steht, was ein JWT eigentlich ist, wie man es korrekt einsetzt und wo es wirklich nicht passt.
Was in einem JWT steckt
Ein JWT besteht aus drei base64url-kodierten Segmenten, durch Punkte verbunden: header.payload.signature. Nur die Signatur hängt von einem Geheimnis ab – Header und Payload sind lediglich kodiert, nicht verschlüsselt, und jeder kann sie dekodieren und lesen.
// header
{ "alg": "HS256", "typ": "JWT" }
// payload (the "claims")
{ "sub": "user_4821", "role": "admin", "exp": 1774300800 }
// signature = HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)
Dieser letzte Punkt verdient Wiederholung, weil er ständig Leute stolpern lässt: Lege niemals etwas Geheimes in die Payload. Ein JWT beweist, dass die Payload seit der Signierung nicht manipuliert wurde – es verbirgt die Payload nicht vor dem Besitzer des Tokens oder vor jedem, dem es gezeigt wird.
Ein Token ausstellen
import jwt from 'jsonwebtoken';
function issueToken(user) {
return jwt.sign(
{
sub: user.id,
role: user.role,
},
process.env.JWT_SECRET,
{ expiresIn: '15m' },
);
}
expiresIn setzt den exp-Claim – das Token lässt sich nach Ablauf dieser Zeit strukturell nicht mehr verifizieren, ohne dass der Server irgendetwas nachhalten muss. Diese Zustandslosigkeit ist der ganze Reiz von JWTs: Jeder Server, der das gemeinsame Secret (oder bei asymmetrischer Signatur den öffentlichen Schlüssel) besitzt, kann ein Token ohne Datenbankabfrage oder Zugriff auf einen Session-Store verifizieren.
Ein Token verifizieren
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'missing_token' });
}
try {
const payload = jwt.verify(authHeader.slice(7), process.env.JWT_SECRET);
req.user = { id: payload.sub, role: payload.role };
next();
} catch (err) {
return res.status(401).json({ error: 'invalid_token' });
}
}
jwt.verify prüft Signatur und Ablauf in einem Aufruf und wirft eine Exception, wenn eines von beidem fehlschlägt. Überlasse das immer der Bibliothek – die Signaturprüfung selbst zu schreiben, ist ein einfacher Weg, einen Timing-Angriff oder einen Type-Confusion-Bug einzubauen (eine bekannte, reale JWT-Schwachstellenklasse betraf Server, die dem alg-Feld im Token selbst vertrauten, sodass ein Angreifer auf alg: none umschalten und die Verifikation komplett umgehen konnte; moderne Bibliotheken verlangen, dass man die akzeptierten Algorithmen explizit angibt, statt der Behauptung des Tokens über seinen eigenen Algorithmus zu vertrauen).
Das Widerrufsproblem
Zustandslosigkeit ist der Verkaufsschlager von JWT und zugleich seine schärfste Kante: Wenn man nie etwas gegen eine Datenbank prüft, kann man auch kein bestimmtes Token vor Ablauf ungültig machen. Ein Nutzer ändert sein Passwort, loggt sich aus oder wird gesperrt – und sein bestehendes Token funktioniert weiter, bis exp erreicht ist, weil „Signatur prüfen" nichts mit der Frage „soll dieses Token noch akzeptiert werden?" zu tun hat.
Drei gängige Wege, damit umzugehen, in aufsteigender Reihenfolge danach, wie viel Zustandslosigkeit man aufgibt:
- Kurze Laufzeit + Refresh-Tokens. Stelle ein kurzlebiges Access-Token (Minuten) und ein langlebigeres, in der Datenbank verfolgtes Refresh-Token aus. Den Zugriff zu widerrufen heißt, den Refresh-Token-Eintrag zu löschen; das Access-Token selbst funktioniert bis zu seinem natürlichen Ablauf weiter, aber dieses Zeitfenster ist klein.
// on login
const accessToken = jwt.sign({ sub: user.id }, secret, { expiresIn: '15m' });
const refreshToken = crypto.randomUUID();
await db.refreshTokens.insert({ token: refreshToken, userId: user.id, expiresAt: /* 30 days */ });
// on refresh
async function refresh(refreshToken) {
const record = await db.refreshTokens.findValid(refreshToken);
if (!record) throw new Error('invalid_refresh_token');
return jwt.sign({ sub: record.userId }, secret, { expiresIn: '15m' });
}
- Eine Denylist für den seltenen, dringenden Fall. Halte einen kleinen, schnell abfragbaren Speicher (Redis, mit einer TTL passend zur Restlaufzeit des Tokens) mit Tokens vor, die sofort abgelehnt werden müssen – ein gestohlenes Token, eine Notfallsperre –, und prüfe ihn bei jeder Anfrage zusätzlich zur Signaturprüfung. Das bringt eine Abfrage auf den Hot Path zurück, aber nur für den Ausnahmefall, nicht für jedes jemals ausgestellte Token.
- Einfach Sessions verwenden. Wenn du eine Denylist baust, die die meisten Tokens die meiste Zeit abdeckt, hast du serverseitige Sessions mit zusätzlichen Schritten neu erfunden. Wenn sofortiges Widerrufen sehr wichtig ist und dein Traffic die dienstübergreifende Zustandslosigkeit von JWT nicht braucht, ist eine klassische Session-ID, gestützt von einer Datenbank oder Redis, einfacher und leistet genau das, worin JWTs strukturell schlecht sind.
Wo die Speicherung Probleme macht
Liegt das Access-Token in localStorage, kann es von jedem JavaScript auf der Seite gelesen werden – auch von einem eingeschleusten Skript aus einer XSS-Lücke irgendwo sonst in der App. Ein httpOnly-Cookie kann von JavaScript gar nicht gelesen werden, was genau diese Lücke schließt, dafür aber CSRF öffnet, weil Browser Cookies automatisch an Anfragen hängen, egal welche Seite sie ausgelöst hat.
res.cookie('access_token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
sameSite: 'strict' (oder mindestens 'lax') ist es, was CSRF hier tatsächlich entschärft – es verhindert, dass der Browser das Cookie an Anfragen von einer anderen Seite anhängt.
Wann JWT das falsche Werkzeug ist
- Ein einzelner Monolith, der ohnehin bei jeder Anfrage seine eigene Datenbank abfragt. Du zahlst die Komplexitätskosten von JWT (Widerrufsstrategie, Verwaltung der Signaturschlüssel) für einen Vorteil durch Zustandslosigkeit, den du gar nicht nutzt, denn ein Session-Lookup würde die Datenbank treffen, die du sowieso schon abfragst.
- Alles, was einen Nutzer als harte Anforderung sofort überall abmelden muss – Passwortzurücksetzung, kompromittiertes Konto. Plane von Anfang an eine Denylist oder das Widerrufen von Refresh-Tokens ein, statt die Lücke erst während eines Vorfalls zu entdecken.
Fazit
Ein JWT ist eine signierte, nicht verschlüsselte Aussage über eine Identität, die ein Server ohne Datenbank-Roundtrip verifizieren kann – wirklich nützlich über Dienstgrenzen hinweg, wo ein gemeinsamer Session-Store umständlich wäre. Der Preis ist, dass Widerruf nicht gratis ist: Entscheide vorab, ob du kurzlebige Tokens mit Refresh brauchst, eine Denylist für Notfälle, oder ob du die Zustandslosigkeit von JWT gar nicht benötigst.