Auth & Sicherheit

JWT Authentication mit Claude Code: Sicherheit 2026

JWT-Authentifizierung sicher implementieren — Claude Code baut Access/Refresh Token Rotation, RS256, Blacklisting und sichere Cookie-Speicherung.

📅 6. Mai 2026 ⏱ 12 min Lesezeit 🔐 Auth & Sicherheit

Was dich erwartet

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!
}
Sicherheitswarnung: Speichere niemals sensible Daten (Passwörter, Zahlungsdaten, vollständige Adressen) im JWT-Payload. JWTs sind Base64-kodiert, nicht verschlüsselt — jeder mit dem Token kann den Payload dekodieren.

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

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 });
}
Performance-Hinweis: Redis-Blacklisting fügt pro Request einen zusätzlichen I/O-Call hinzu. Bei sehr hohem Traffic: Redis Cluster oder lokales In-Memory-Cache als First-Layer verwenden. TTL muss exakt der restlichen Token-Lebensdauer entsprechen — zu kurz = Sicherheitslücke, zu lang = Speicherverschwendung.

5. Sichere Cookie-Storage

httpOnly secure sameSite — 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

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

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?

PKCE Pflicht (RFC 9700, 2025): Seit RFC 9700 ist PKCE für ALLE OAuth-Clients verpflichtend — nicht nur für SPAs. Implicit Flow ist deprecated und sollte nirgends mehr eingesetzt werden.

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 →