JWT (JSON Web Token, RFC 7519) ist ein verbreitetes Format für stateless Authentication-Tokens: Server signiert einen JSON-Payload, Client schickt ihn bei jedem Request zurück, Server validiert Signatur — kein Session-Store nötig. Die Klasse hat aber berüchtigte Stolperfallen: der alg=none-Bug, HS/RS-Confusion, schwache Secrets, fehlende Audience-Checks. Plus das fundamentale Revocation-Problem. Dieser Artikel zeigt, wo JWT richtig sitzt und wo Session-basiert die bessere Wahl ist.
Was JWT ist (und was nicht)
Ein JWT besteht aus drei Base64-codierten Teilen, durch Punkte getrennt:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3MDB9.signature
│ │ │
│ │ └─ Signatur (HMAC oder asym.)
│ └─ Payload (Claims)
└─ Header (Algorithmus, Typ)Header decodiert:
{ "alg": "HS256", "typ": "JWT" }Payload decodiert:
{
"sub": "user-123",
"iss": "https://auth.example.com",
"aud": "https://api.example.com",
"exp": 1700000000,
"iat": 1699998000,
"jti": "unique-token-id"
}Wichtige Klarstellung: Der Payload ist nicht verschlüsselt, nur Base64-codiert. Jeder mit Zugriff auf das Token kann ihn lesen. JWT signiert, verschlüsselt nicht. Wer Verschlüsselung braucht, nutzt JWE (JSON Web Encryption) — selten in der Praxis.
Standard-Claims (RFC 7519):
iss(Issuer) — wer hat das Token ausgestelltsub(Subject) — wem gehört es (User-ID)aud(Audience) — wer darf es nutzen (API-URL)exp(Expiration) — wann läuft es ab (Unix-Timestamp)iat(Issued At) — wann wurde es erstelltnbf(Not Before) — frühestens ab wann gültigjti(JWT ID) — eindeutige Token-ID (für Revocation)
Der berüchtigte alg=none-Bug
In der JWT-Spezifikation gibt es einen Algorithmus namens none — formal „unsigniert". Wenn ein Validator naiv den alg-Header der Token-Header-Section liest und danach den Validations-Pfad wählt, kann ein Angreifer:
- Token nehmen, dekodieren.
- Payload manipulieren (
"sub": "admin"). algaufnonesetzen, Signatur-Teil leer lassen.- Wieder zusammenbauen, an Server schicken.
Wenn der Validator alg=none akzeptiert: Token gilt als gültig, Angreifer ist als Admin eingeloggt.
Schutz:
- Algorithm Allowlist im Validator-Code — niemals den Header-
algeinfach übernehmen. - Moderne Libraries (jose, jsonwebtoken seit v9, PyJWT seit 2.0) lehnen
alg=nonestandardmäßig ab. - Library-Update auditieren: jsonwebtoken vor v9 hatte den
alg=none-Bug als Default.
const jwt = require('jsonwebtoken');
// Sicher: Allowlist von erlaubten Algorithmen
const payload = jwt.verify(token, SECRET, {
algorithms: ['HS256'], // EXPLIZITE Allowlist
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});HS256/RS256-Algorithm-Confusion
Ein anderer Klassiker:
- HS256 ist HMAC mit symmetrischem Secret.
- RS256 ist RSA mit asymmetrischem Schlüsselpaar (Private Key zum Signieren, Public Key zum Validieren).
Confusion-Angriff: Ein Server, der RS256 nutzt, validiert mit dem Public Key. Wenn ein Angreifer ein Token mit alg=HS256 schickt und der Validator den Public Key als Secret für HMAC-Validation nutzt, kann der Angreifer ein Token mit dem öffentlich bekannten Public Key signieren — Auth-Bypass.
Schutz:
- Strikte Algorithm-Allowlist (nur
RS256, nichtHS256-Fallback). - Modern: separate Key-Stores für HS und RS, keine Überschneidung.
Beispiel-Angriff-Schritt:
1. Server-Architektur: signiert JWTs mit RS256, Public Key auf /.well-known/jwks.json
2. Angreifer holt Public Key
3. Angreifer baut neues JWT mit alg=HS256, Payload "sub": "admin"
4. Angreifer signiert mit HMAC-SHA256(public_key_string)
5. Server: liest alg=HS256 → nutzt Public Key als Secret → Signatur valid → Auth-BypassLibrary-State 2026: Moderne Libraries verlangen explizite Algorithm-Wahl beim Verify. jwt.verify(token, key) ohne algorithms-Parameter wirft Warning oder Error.
Schwache Secrets bei HS256
Bei HS256 ist der Secret-String der einzige Schutz. Wenn er ratbar ist, kann jeder Tokens fälschen.
Anti-Pattern (häufig in Beispielen und Tutorials):
// ALLES schadhaft
const secret = 'secret';
const secret = 'my-jwt-secret';
const secret = 'changeme123';
const secret = process.env.JWT_SECRET; // wenn JWT_SECRET nicht gesetzt → undefined → fail-open in alter LibTools wie jwt_tool oder jwt-cracker probieren Wörterbücher und schwache Strings in Sekunden durch.
Schutz:
- HS256-Secret mindestens 256 Bit (32 Bytes) zufällig —
crypto.randomBytes(32).toString('base64'). - In Secret-Manager (AWS Secrets Manager, HashiCorp Vault, K8s Secrets) speichern, nicht in
.envcommitten. - Rotation-Strategie definiert (siehe Abschnitt zu Rotation).
Besser noch: RS256 oder EdDSA mit Schlüsselpaar. Public Key kann veröffentlicht werden (JWKS-Endpoint), Private Key bleibt im Auth-Server. Andere Services können Token validieren, ohne das Secret zu kennen — wichtig in Microservice-Architekturen.
Audience und Issuer Validation
Ein Klassiker im Microservice-Setup: Service A und Service B akzeptieren beide JWTs vom selben Auth-Server. Wenn ein Token für Service A auch auf Service B gültig ist, kann es zu Cross-Service-Token-Reuse kommen.
Schutz: aud-Claim immer prüfen.
const payload = jwt.verify(token, PUBLIC_KEY, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com', // MUSS passen
audience: 'https://orders.example.com', // MUSS passen
clockTolerance: 5, // 5s Clock-Skew erlauben
});Wenn aud nicht zur eigenen Service-URL passt → Token ablehnen, auch wenn Signatur valide ist.
iss-Check: Wenn die App nur Tokens von einem bestimmten Auth-Server akzeptiert (was meistens der Fall ist), explizit prüfen. Verhindert Multi-Tenant-Token-Confusion.
Token-Storage im Browser
Wo legt eine SPA das JWT ab?
| Storage | XSS-Schutz | CSRF-Schutz | Praktikabilität |
|---|---|---|---|
| localStorage | Kein — JS kann lesen | Voll (kein automatisches Senden) | Standard-Pattern in vielen Tutorials |
| sessionStorage | Kein — JS kann lesen | Voll | Wie localStorage, plus Tab-scoped |
| HttpOnly-Cookie | Voll — JS kann nicht lesen | Kein (Browser sendet automatisch) — CSRF-Token nötig | |
| In-Memory (JS-Variable) | Voll bei Tab-Refresh weg | Voll | Sicher, aber UX: Refresh = Logout |
Konsens (Stand 2026):
- HttpOnly-Cookie ist die strukturell sicherste Option — aber dann hat man kein „stateless JWT für Header"-Pattern mehr, sondern eine Cookie-Session, die zufällig ein JWT als Format hat.
- localStorage ist verbreitet aber XSS-anfällig — XSS-Lücke = Account-Übernahme.
- In-Memory ist die theoretisch sicherste Option, aber UX-feindlich (Refresh-Logout).
Hybrid-Pattern (empfehlenswert): Refresh-Token als HttpOnly-Cookie, Access-Token in-memory. Access-Token läuft schnell ab (5–15 min), Refresh holt neues. Bei Tab-Refresh wird der Access-Token aus dem Refresh-Cookie regeneriert — kein Logout.
// Backend: /login
const accessToken = jwt.sign({ sub: user.id, type: 'access' }, KEY,
{ expiresIn: '15m', algorithm: 'RS256' });
const refreshToken = jwt.sign({ sub: user.id, type: 'refresh', jti: uuid() },
KEY, { expiresIn: '7d', algorithm: 'RS256' });
// Refresh-Token in HttpOnly-Cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true, secure: true, sameSite: 'strict', path: '/auth/refresh',
});
// Access-Token in Response-Body — Client legt im Speicher ab
res.json({ accessToken });
// /auth/refresh — Rotation
app.post('/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
const decoded = jwt.verify(refreshToken, KEY, { algorithms: ['RS256'] });
// jti-Tracking: refresh-token nur einmal nutzbar (Detection von Token-Theft)
if (await refreshTokenRevoked(decoded.jti)) {
return res.status(401).end();
}
await markRefreshTokenUsed(decoded.jti);
const newAccess = jwt.sign({ sub: decoded.sub, type: 'access' }, KEY, { expiresIn: '15m' });
const newRefresh = jwt.sign({ sub: decoded.sub, type: 'refresh', jti: uuid() }, KEY, { expiresIn: '7d' });
res.cookie('refreshToken', newRefresh, {/* ... */});
res.json({ accessToken: newAccess });
});Refresh-Token-Rotation: Bei jedem Refresh ein neues Refresh-Token ausgeben, altes invalidieren. Wenn ein Angreifer ein altes Refresh-Token nochmal nutzt → Detection, alle Tokens des Users invalidieren.
Das Revocation-Problem
Die fundamentale Schwäche von stateless JWT: wie widerrufen?
Wenn ein Token kompromittiert wird (XSS, Phishing) oder ein User „Aus allen Geräten ausloggen" klickt — das stateless Token bleibt valide bis zur exp-Zeit. Server kann nichts dagegen tun, weil er keinen State über Token hält.
Lösungs-Patterns:
-
Sehr kurze Token-Lebensdauer (5–15 min Access-Token). Refresh-Token mit Rotation. Damit ist das Zeitfenster für Angriffe klein. Häufigster Ansatz.
-
Token-Blacklist (revocation list): jeden
jtirevokierter Tokens in Redis/DB speichern, jeder Validator prüft. Praktisch ein Session-Store mit Inversem Lookup. Erodiert den „stateless"-Vorteil. -
Token-Whitelist (allow list): nur Tokens akzeptieren, deren
jtiaktiv im Store ist. Quasi Session-basiert mit JWT-Format. Voller Revocation-Support. -
User-Token-Version: User-Record hat
tokenVersion(Integer). JWT enthält die Version. Bei Logout-Everywhere: Version inkrementieren — alle alten Tokens invalid. Pragmatischer Kompromiss.
// User-Tabelle: tokenVersion INT DEFAULT 0
const token = jwt.sign({
sub: user.id,
tokenVersion: user.tokenVersion,
}, KEY);
// Validierung
const payload = jwt.verify(token, KEY);
const user = await db.users.findOne({ id: payload.sub });
if (user.tokenVersion !== payload.tokenVersion) {
throw new Error('Token revoked');
}
// Logout-Everywhere
await db.users.update({ id: userId }, { tokenVersion: { $inc: 1 } });Faustregel: Wenn deine App Revocation braucht (und das brauchen die meisten), ist „echtes stateless JWT" eine Illusion. Pragmatisch: kurze Access-Token + Refresh-Rotation + User-Token-Version für Notfälle.
Library-Patterns
Node.js (jsonwebtoken):
const jwt = require('jsonwebtoken');
// Sign
const token = jwt.sign({ sub: user.id }, PRIVATE_KEY, {
algorithm: 'RS256',
expiresIn: '15m',
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
jwtid: crypto.randomUUID(),
});
// Verify
const payload = jwt.verify(token, PUBLIC_KEY, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});Node.js (jose — moderner, RFC-compliant):
import { SignJWT, jwtVerify } from 'jose';
const jwt = await new SignJWT({ sub: user.id })
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
.setIssuer('https://auth.example.com')
.setAudience('https://api.example.com')
.setExpirationTime('15m')
.sign(privateKey);
const { payload } = await jwtVerify(jwt, publicKey, {
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});Python (PyJWT):
import jwt
token = jwt.encode(
{ 'sub': user.id, 'exp': time.time() + 900 },
PRIVATE_KEY,
algorithm='RS256',
)
payload = jwt.decode(
token,
PUBLIC_KEY,
algorithms=['RS256'],
issuer='https://auth.example.com',
audience='https://api.example.com',
)Go (golang-jwt/jwt):
import "github.com/golang-jwt/jwt/v5"
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"sub": userID,
"exp": time.Now().Add(15 * time.Minute).Unix(),
})
signedToken, _ := token.SignedString(privateKey)
parsed, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("invalid alg")
}
return publicKey, nil
})Häufige Stolperfallen
JWT-Bibliotheken vor 2020 sind nicht safe-by-default
jsonwebtoken vor v9 akzeptierte alg=none standardmäßig. PyJWT vor 2.0 hatte HS/RS-Confusion-Bugs. Alte Java-Libraries (Auth0 java-jwt vor 4.x) hatten ähnliche Probleme. Library-Version explizit auf den aktuellen Stand pinnen; CVE-Listen pro Library checken.
Token-Lebensdauer ist die wichtigste Stellschraube
Lange JWTs (24h+) sind ein Sicherheits-Anti-Pattern. Bei Kompromittierung läuft das Token zu lange weiter. Empfehlung: Access-Token: 15 Minuten, Refresh-Token: 7 Tage mit Rotation und Detection. Bei sehr sensitiven Apps: 5 Minuten Access, 24 Stunden Refresh.
JWT als Session-Replacement ist meistens overengineering
Für eine klassische Web-App mit Single-Server oder kleinem Cluster ist eine Cookie-Session einfacher, sicherer und löst Revocation trivial. JWT lohnt sich erst bei verteilten Service-Architekturen oder echtem Microservice-Setup, wo der Validator-Service nicht jeden Request gegen einen Session-Store machen kann.
`exp` ist die Mindest-Pflicht-Claim
Ein JWT ohne exp läuft theoretisch ewig. Auch ohne den klassischen Token-Theft-Vektor: ein User, der vor 3 Jahren ausgeschieden ist, hat noch ein gültiges Token. exp ist nicht optional — auch in Quick-Demos nicht weglassen, weil aus Demos oft Produktiv-Code wird.
Public-Key-Wechsel ist eine eigene Operation
Bei RS256 muss der Public Key beim Validator bekannt sein. Klassisches Pattern: JWKS (/.well-known/jwks.json) mit Key-ID (kid) im JWT-Header. Bei Schlüssel-Rotation: neuer Key mit neuem kid publizieren, alter Key bleibt eine Weile in JWKS (Overlap-Period), dann wird er entfernt. Wer das hart umstellt, brickt aktive Sessions.
JWT in Logs als PII-Risiko
JWT enthält oft User-ID, E-Mail, Name. In Server-Logs oder Error-Reporting landet er häufig als Header — und damit User-Daten in Logs. Authorization-Header in Logs filtern (Express: morgan-Config mit Skip; Sentry: beforeSend-Hook). Bei DSGVO-Audit ein typischer Fund.
Audience-Confusion bei Cross-Tenant-JWT
Wenn Tenant A und Tenant B beide vom selben Auth-Server JWTs bekommen, müssen die Tokens unterschiedliche aud haben — sonst kann ein Token aus Tenant A bei Tenant B genutzt werden (wenn der Validator aud nicht prüft). Ist konsequent als Multi-Tenant-Bug-Klasse in Bug-Bounty-Reports zu finden.
Weiterführende Ressourcen
Externe Quellen
- OWASP JWT Cheat Sheet
- RFC 7519 — JSON Web Token
- RFC 8725 — JWT Best Current Practices
- RFC 7517 — JSON Web Key (JWK) and JWKS
- jwt_tool — JWT-Pentesting-Werkzeug
- jose (Node.js / RFC-compliant)
- PyJWT
- golang-jwt/jwt