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:

Plain jwt-structure.txt
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3MDB9.signature
│                                  │                                  │
│                                  │                                  └─ Signatur (HMAC oder asym.)
│                                  └─ Payload (Claims)
└─ Header (Algorithmus, Typ)

Header decodiert:

JSON jwt-header.json
{ "alg": "HS256", "typ": "JWT" }

Payload decodiert:

JSON jwt-payload.json
{
  "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 ausgestellt
  • sub (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 erstellt
  • nbf (Not Before) — frühestens ab wann gültig
  • jti (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:

  1. Token nehmen, dekodieren.
  2. Payload manipulieren ("sub": "admin").
  3. alg auf none setzen, Signatur-Teil leer lassen.
  4. 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-alg einfach übernehmen.
  • Moderne Libraries (jose, jsonwebtoken seit v9, PyJWT seit 2.0) lehnen alg=none standardmäßig ab.
  • Library-Update auditieren: jsonwebtoken vor v9 hatte den alg=none-Bug als Default.
JavaScript jwt-secure-verify.js
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, nicht HS256-Fallback).
  • Modern: separate Key-Stores für HS und RS, keine Überschneidung.

Beispiel-Angriff-Schritt:

Plain hs-rs-confusion-attack.txt
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-Bypass

Library-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):

JavaScript weak-jwt-secret.js
// 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 Lib

Tools 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älligcrypto.randomBytes(32).toString('base64').
  • In Secret-Manager (AWS Secrets Manager, HashiCorp Vault, K8s Secrets) speichern, nicht in .env committen.
  • 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.

JavaScript jwt-audience-issuer-check.js
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?

StorageXSS-SchutzCSRF-SchutzPraktikabilität
localStorageKein — JS kann lesenVoll (kein automatisches Senden)Standard-Pattern in vielen Tutorials
sessionStorageKein — JS kann lesenVollWie localStorage, plus Tab-scoped
HttpOnly-CookieVoll — JS kann nicht lesenKein (Browser sendet automatisch) — CSRF-Token nötig
In-Memory (JS-Variable)Voll bei Tab-Refresh wegVollSicher, 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.

JavaScript access-refresh-token-pattern.js
// 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:

  1. Sehr kurze Token-Lebensdauer (5–15 min Access-Token). Refresh-Token mit Rotation. Damit ist das Zeitfenster für Angriffe klein. Häufigster Ansatz.

  2. Token-Blacklist (revocation list): jeden jti revokierter Tokens in Redis/DB speichern, jeder Validator prüft. Praktisch ein Session-Store mit Inversem Lookup. Erodiert den „stateless"-Vorteil.

  3. Token-Whitelist (allow list): nur Tokens akzeptieren, deren jti aktiv im Store ist. Quasi Session-basiert mit JWT-Format. Voller Revocation-Support.

  4. User-Token-Version: User-Record hat tokenVersion (Integer). JWT enthält die Version. Bei Logout-Everywhere: Version inkrementieren — alle alten Tokens invalid. Pragmatischer Kompromiss.

JavaScript token-version-pattern.js
// 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):

JavaScript jwt-node-pattern.js
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):

JavaScript jose-node-pattern.js
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):

Python jwt-python-pattern.py
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):

Go jwt-go-pattern.go
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

/ Weiter

Zurück zu Authentifizierung (Entwickler)

Zur Übersicht