Was dich erwartet
- JWT-Grundlagen: Header, Payload, Signature — HS256 vs. RS256
- Access & Refresh Token Rotation mit kurzen Laufzeiten
- Asymmetrische Schlüssel mit RS256 und der jose-Library
- Redis-basiertes Token Blacklisting bei Logout
- Sichere Cookie-Storage mit httpOnly, sameSite und CSRF-Schutz
- PKCE OAuth-Flow für sichere Authorization Code Grants
1. JWT-Grundlagen
Ein JSON Web Token (JWT) besteht aus drei Base64url-kodierten Teilen, getrennt durch Punkte:
Header.Payload.Signature. Der Header beschreibt den Algorithmus, der Payload enthält Claims,
und die Signatur stellt die Integrität sicher.
HS256 RS256 jose jsonwebtoken
HS256 vs. RS256
HS256 (HMAC-SHA256) verwendet einen einzelnen geteilten Secret-Key — einfach, aber gefährlich wenn der Key kompromittiert wird. RS256 (RSA-SHA256) trennt Signierung (privater Key) von Verifikation (öffentlicher Key) — ideal für Microservices und externe Token-Validierung.
// Installation
// npm install jose jsonwebtoken
// npm install -D @types/jsonwebtoken
import { SignJWT, jwtVerify } from 'jose';
import jwt from 'jsonwebtoken';
import { randomUUID } from 'crypto';
// JWT Payload-Design — was gehört rein?
interface JWTPayload {
sub: string; // User-ID (Subject)
email: string; // Nur nicht-sensible Daten!
role: string; // Berechtigungsebene
jti: string; // JWT ID — für Blacklisting
iat?: number; // Issued At (auto)
exp?: number; // Expiration (auto)
}
// HS256 — nur für einfache Monolithen
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
async function signHS256(payload: JWTPayload): Promise<string> {
return new SignJWT({ ...payload })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(SECRET);
}
// Payload-Design Best Practices
function buildPayload(user: { id: string; email: string; role: string }): JWTPayload {
return {
sub: user.id,
email: user.email,
role: user.role,
jti: randomUUID(), // Eindeutige ID pro Token
};
// NIEMALS: Passwort, Kreditkartendaten, vollständige Adresse!
}
2. Access und Refresh Tokens
Das Dual-Token-Pattern ist der Kern moderner Auth-Systeme: Access Token (kurzlebig, 15 Minuten) für API-Calls und Refresh Token (langlebig, 7 Tage) für stille Token-Erneuerung.
Rotation-Pattern
Bei jedem Refresh wird der alte Refresh Token invalidiert und ein neuer ausgestellt. Dieses Refresh Token Rotation-Muster verhindert Token-Diebstahl durch Replay-Angriffe.
import { SignJWT, jwtVerify } from 'jose';
import { randomUUID } from 'crypto';
const ACCESS_SECRET = new TextEncoder().encode(process.env.ACCESS_SECRET!);
const REFRESH_SECRET = new TextEncoder().encode(process.env.REFRESH_SECRET!);
// Access Token: 15 Minuten, kompakter Payload
export async function createAccessToken(userId: string, role: string): Promise<string> {
return new SignJWT({ sub: userId, role, jti: randomUUID() })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m') // Kurze Lebensdauer!
.sign(ACCESS_SECRET);
}
// Refresh Token: 7 Tage, eigener Secret
export async function createRefreshToken(userId: string): Promise<string> {
return new SignJWT({ sub: userId, jti: randomUUID() })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d') // Lange Lebensdauer, sicher speichern!
.sign(REFRESH_SECRET);
}
// Token Pair generieren
export async function generateTokenPair(userId: string, role: string) {
const [accessToken, refreshToken] = await Promise.all([
createAccessToken(userId, role),
createRefreshToken(userId),
]);
return { accessToken, refreshToken };
}
// Refresh-Endpoint: alten Token invalidieren, neues Pair ausstellen
export async function refreshTokenHandler(req: Request, res: Response) {
const oldRefresh = req.cookies['refresh_token'];
if (!oldRefresh) return res.status(401).json({ error: 'No refresh token' });
try {
const { payload } = await jwtVerify(oldRefresh, REFRESH_SECRET);
const userId = payload.sub as string;
// Alten Refresh Token blacklisten (Rotation!)
await redis.setex(`blacklist:${payload.jti}`, 7 * 24 * 3600, '1');
// Neues Token Pair ausstellen
const { accessToken, refreshToken } = await generateTokenPair(userId, 'user');
res.cookie('refresh_token', refreshToken, {
httpOnly: true, secure: true, sameSite: 'strict', maxAge: 7 * 24 * 3600 * 1000,
});
res.json({ accessToken });
} catch {
res.status(401).json({ error: 'Invalid refresh token' });
}
}
3. RS256 und asymmetrische Keys
RS256 ist der Goldstandard für Produktions-JWTs. Mit openssl generierst du ein RSA-Schlüsselpaar — der private Key signiert, der öffentliche Key verifiziert. Microservices benötigen nur den Public Key zum Validieren.
# RSA-Schlüsselpaar generieren (2048 Bit Minimum, 4096 empfohlen)
openssl genrsa -out private.pem 4096
openssl rsa -in private.pem -pubout -out public.pem
# Keys als Umgebungsvariablen setzen (zeilenweise mit \n)
# JWT_PRIVATE_KEY="$(cat private.pem)"
# JWT_PUBLIC_KEY="$(cat public.pem)"
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from 'jose';
import { randomUUID } from 'crypto';
// Keys aus Umgebungsvariablen laden (einmalig beim Start)
let privateKey: CryptoKey;
let publicKey: CryptoKey;
export async function initKeys() {
privateKey = await importPKCS8(process.env.JWT_PRIVATE_KEY!.replace(/\\n/g, '\n'), 'RS256');
publicKey = await importSPKI(process.env.JWT_PUBLIC_KEY!.replace(/\\n/g, '\n'), 'RS256');
console.log('[Auth] RSA keys loaded ✓');
}
// RS256 Token signieren (privater Key)
export async function signRS256Token(userId: string, role: string): Promise<string> {
return new SignJWT({
sub: userId,
role,
jti: randomUUID(),
})
.setProtectedHeader({ alg: 'RS256', kid: 'v1' }) // kid für Key-Rotation
.setIssuedAt()
.setIssuer('https://agentic-movers.com')
.setAudience('https://api.agentic-movers.com')
.setExpirationTime('15m')
.sign(privateKey);
}
// Token verifizieren (öffentlicher Key — kann verteilt werden!)
export async function verifyToken(token: string) {
const { payload } = await jwtVerify(token, publicKey, {
issuer: 'https://agentic-movers.com',
audience: 'https://api.agentic-movers.com',
});
return payload;
}
// JWKS-Endpoint für externe Services (Public Key Distribution)
import { exportJWK } from 'jose';
export async function getJWKS() {
const jwk = await exportJWK(publicKey);
return {
keys: [{ ...jwk, kid: 'v1', use: 'sig', alg: 'RS256' }],
};
}
// GET /.well-known/jwks.json → Microservices validieren ohne shared secret!
RS256 Vorteile in der Praxis
- Auth-Service signiert — API-Services verifizieren ohne geteilten Secret
- Public Key kann öffentlich über JWKS-Endpoint exponiert werden
- Key-Rotation ohne Downtime via kid (Key ID)
- Microservices sind stateless — kein Datenbankaufruf für Verifikation
4. Token Blacklisting mit Redis
JWTs sind von Natur aus stateless — aber was passiert beim Logout oder bei
kompromittierten Tokens? Die Lösung: Redis-basiertes Blacklisting via
jti-Claim (JWT ID). Jeder Token bekommt eine eindeutige ID,
die bei Logout in Redis mit TTL eingetragen wird.
import Redis from 'ioredis';
import { jwtVerify } from 'jose';
import type { Request, Response, NextFunction } from 'express';
const redis = new Redis(process.env.REDIS_URL!);
// Blacklist-Key Format: blacklist:{jti}
const blacklistKey = (jti: string) => `blacklist:${jti}`;
// Token bei Logout blacklisten
export async function blacklistToken(jti: string, ttlSeconds: number) {
await redis.setex(blacklistKey(jti), ttlSeconds, '1');
// TTL = restliche Token-Lebensdauer → automatisches Cleanup!
}
// Blacklist-Status prüfen
export async function isBlacklisted(jti: string): Promise<boolean> {
const result = await redis.exists(blacklistKey(jti));
return result === 1;
}
// Auth-Middleware mit Blacklist-Check
export async function authMiddleware(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized' });
}
const token = authHeader.slice(7);
try {
const { payload } = await jwtVerify(token, publicKey);
const jti = payload.jti as string;
// Blacklist-Check VOR jeder Anfrage
if (await isBlacklisted(jti)) {
return res.status(401).json({ error: 'Token revoked' });
}
req.user = { id: payload.sub as string, role: payload.role as string, jti };
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
}
// Logout-Handler
export async function logoutHandler(req: Request, res: Response) {
const { jti } = req.user!;
const ttl = 15 * 60; // 15 Minuten (Access Token Lebensdauer)
await blacklistToken(jti, ttl);
// Refresh Token Cookie löschen
res.clearCookie('refresh_token');
res.json({ success: true });
}
5. Sichere Cookie-Storage
— Diese drei Cookie-Flags sind nicht optional, sie sind Pflicht. localStorage ist für JWTs unsicher: XSS-Angriffe können jeden gespeicherten Token lesen.
Cookie vs. localStorage
- localStorage: Zugänglich via JavaScript → XSS-Angriff liest alle Tokens sofort aus
- sessionStorage: Nur für diese Tab-Session, kein persistentes Login
- httpOnly Cookie: JavaScript kann nicht darauf zugreifen → XSS-sicher
import type { CookieOptions } from 'express';
// Sichere Cookie-Konfiguration
const REFRESH_COOKIE_OPTIONS: CookieOptions = {
httpOnly: true, // Kein JS-Zugriff → XSS-Schutz
secure: true, // Nur über HTTPS
sameSite: 'strict', // Kein Cross-Site Senden → CSRF-Schutz
domain: '.agentic-movers.com', // Nur eigene Domain
path: '/api/auth', // Nur Auth-Endpunkte
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 Tage in ms
};
// Login: Token Pair setzen
export async function loginHandler(req: Request, res: Response) {
const { email, password } = req.body;
const user = await validateCredentials(email, password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const { accessToken, refreshToken } = await generateTokenPair(user.id, user.role);
// Refresh Token → sicherer httpOnly Cookie
res.cookie('refresh_token', refreshToken, REFRESH_COOKIE_OPTIONS);
// Access Token → Response Body (kurze Lebensdauer, im Memory halten)
res.json({ accessToken, expiresIn: 900 }); // 900s = 15min
}
// CSRF-Schutz zusätzlich mit Double-Submit-Cookie
import { randomBytes } from 'crypto';
export function setCSRFToken(res: Response) {
const csrfToken = randomBytes(32).toString('hex');
// CSRF-Cookie: NICHT httpOnly (JS muss es lesen und als Header senden)
res.cookie('csrf_token', csrfToken, {
secure: true,
sameSite: 'strict',
maxAge: 24 * 3600 * 1000,
});
return csrfToken;
}
// CSRF-Middleware: Header-Wert muss mit Cookie übereinstimmen
export function csrfMiddleware(req: Request, res: Response, next: NextFunction) {
const cookieToken = req.cookies['csrf_token'];
const headerToken = req.headers['x-csrf-token'];
if (!cookieToken || cookieToken !== headerToken) {
return res.status(403).json({ error: 'CSRF validation failed' });
}
next();
}
Cookie Security Checkliste
httpOnly: true— JavaScript kann Token nicht lesensecure: true— Nur über HTTPS übertragensameSite: 'strict'— Kein Cross-Origin Sendenpath: '/api/auth'— Minimaler Scope (nicht '/')- CSRF-Double-Submit für State-Mutations (POST/PUT/DELETE)
- Access Token NIEMALS in Cookie — im Memory halten (Variable)
6. PKCE OAuth-Flow
PKCE (Proof Key for Code Exchange) ist der sichere Standard für Authorization Code Flows in SPAs und Mobile Apps — ohne Client Secret. Es verhindert Authorization Code Interception Attacks.
PKCE Funktionsweise
Der Client generiert einen zufälligen code_verifier, berechnet daraus den
code_challenge (SHA-256 Hash, Base64url-kodiert) und sendet nur den Challenge
zum Authorization Server. Beim Token Exchange schickt er den originalen Verifier —
nur der legitime Client kann die Challenge lösen.
// PKCE Code Verifier + Challenge generieren (Browser/Node)
export function generateCodeVerifier(): string {
const array = new Uint8Array(64);
crypto.getRandomValues(array);
return base64url(array);
}
export async function generateCodeChallenge(verifier: string): Promise<string> {
const data = new TextEncoder().encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64url(new Uint8Array(hash));
}
function base64url(input: Uint8Array): string {
return btoa(String.fromCharCode(...input))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
// Schritt 1: Authorization URL bauen (inkl. Code Challenge)
export async function startPKCEFlow(authServerUrl: string, clientId: string) {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = crypto.randomUUID();
// Verifier sicher im sessionStorage (nur diese Tab-Session)
sessionStorage.setItem('pkce_verifier', verifier);
sessionStorage.setItem('pkce_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: 'https://agentic-movers.com/callback',
scope: 'openid profile email',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
window.location.href = `${authServerUrl}/authorize?${params}`;
}
// Schritt 2: Authorization Code → Token Exchange
export async function handleCallback(code: string, state: string) {
const savedState = sessionStorage.getItem('pkce_state');
const verifier = sessionStorage.getItem('pkce_verifier');
// State validieren (CSRF-Schutz)
if (state !== savedState) throw new Error('State mismatch — possible CSRF!');
const response = await fetch('/api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, code_verifier: verifier }),
});
sessionStorage.removeItem('pkce_verifier');
sessionStorage.removeItem('pkce_state');
return response.json(); // { accessToken, expiresIn }
}
// PKCE mit Supabase Auth (eingebaut!)
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!, {
auth: {
flowType: 'pkce', // PKCE automatisch aktiviert
detectSessionInUrl: true,
storage: sessionStorage, // Sicherer als localStorage
},
});
// Auth0 PKCE (automatisch via @auth0/auth0-react)
// import { Auth0Provider } from '@auth0/auth0-react';
// <Auth0Provider domain="..." clientId="..." authorizationParams={{ ... }}>
PKCE Warum ohne Client Secret?
- SPAs und Mobile Apps können kein Secret sicher speichern (Quellcode ist öffentlich)
- PKCE ersetzt das Secret durch kryptografischen Proof (Challenge/Verifier)
- Authorization Codes sind wertlos ohne passenden Verifier
- Supabase und Auth0 unterstützen PKCE nativ — kein Extra-Code nötig
JWT-Auth mit KI-Unterstützung bauen
Claude Code analysiert deinen bestehenden Auth-Stack und schlägt konkrete Verbesserungen vor — RS256, Token Rotation, sichere Cookies und PKCE in Minuten implementiert.
Kostenlos testen →