2FA mit Claude Code implementieren: TOTP, Backup-Codes, Recovery und Step-Up Auth
Praxisleitfaden für 2FA/MFA in Next.js mit Claude Code: TOTP, Backup-Codes, Recovery, Limits, Passkeys und Audit.
2FA ist nicht nur ein QR-Code auf einer Einstellungsseite. Eine produktionsreife 2FA/MFA-Implementierung braucht TOTP, Backup-Codes, Step-Up Auth für sensible Aktionen, Recovery, Rate Limits, gemerkte Geräte, Audit-Logs und einen Pfad zu WebAuthn/passkeys.
Dieser Leitfaden zeigt, wie ich Claude Code für eine Next.js App Router Anwendung mit TypeScript und Prisma einsetzen würde. Für die Login-Basis passt der Claude Code JWT Authentication Guide, für Passwort-Wiederherstellung der Password Reset Guide, für Berechtigungen der RBAC Guide, und für Agentenrechte die Claude Code Security Best Practices.
Als externe Grundlage dienen OWASP MFA Cheat Sheet, OWASP WSTG MFA Testing, MDN Web Authentication API und W3C WebAuthn Level 3. Diese Quellen machen klar: MFA betrifft auch Speicherung, Fehlversuche, Recovery und Faktorwechsel.
Sicherheitsgrenze zuerst
TOTP bedeutet time-based one-time password. Server und Authenticator-App teilen ein Secret und erzeugen meist alle 30 Sekunden einen kurzen Code. MFA erhöht die Vertrauensstufe einer Session, ersetzt aber keine Autorisierung. Admin-Einladungen, Billing-Änderungen, API-Key-Erstellung, Passwortänderung und Datenexport sollten eine frische MFA verlangen.
flowchart TD
A["Password or SSO login"] --> B{"2FA enabled?"}
B -->|No| C["Lower-assurance session"]
B -->|Yes| D["TOTP or backup code challenge"]
D --> E{"Valid and rate limit OK?"}
E -->|No| F["Deny and audit"]
E -->|Yes| G["Session with mfaAt"]
G --> H{"Sensitive action?"}
H -->|Yes| I{"mfaAt fresh?"}
I -->|No| D
I -->|Yes| J["Allow"]
H -->|No| J
Geben Sie Claude Code diese Grenze zuerst. Bitten Sie nicht um „baue 2FA“ in einem Stück. Teilen Sie die Arbeit in Datenmodell, Crypto-Helper, Setup-API, Enable-API, Login-Challenge, Recovery, Remembered Device, Audit-Logs und Tests. Produktionsschlüssel, KMS, Support-Regeln und Admin-Rechte bleiben menschliche Entscheidungen.
Implement 2FA for a Next.js App Router app with TypeScript and Prisma.
Scope: TOTP, backup codes, remembered devices, step-up auth, recovery events, audit logs.
Rules:
- Encrypt TOTP secrets with AES-256-GCM before saving.
- Hash backup codes and remembered device tokens with HMAC-SHA256.
- Apply rate limits to setup, enable, login challenge, and recovery.
- Do not make SMS the primary factor.
- Keep a migration path to WebAuthn/passkeys.
First output the Prisma schema and review checklist only.
Prüfen Sie danach hart: TOTP Secret im Klartext, Backup-Codes im Klartext oder Log, wiederverwendbare Codes, kein Rate Limit für sechsstellige Codes, rohe Remembered-Device-Tokens in der Datenbank, Recovery durch eine einzelne Supportperson und fehlende Audit-Events für Aktivierung, Deaktivierung, Fehler, Recovery und Faktorwechsel.
Datenmodell
Das TOTP Secret wird verschlüsselt, weil der Server es später lesen muss. Backup-Codes und Remembered-Device-Tokens werden gehasht, weil nur der Vergleich nötig ist.
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
twoFactorEnabled Boolean @default(false)
twoFactorEnabledAt DateTime?
twoFactorSecretCiphertext String?
twoFactorSecretIv String?
twoFactorSecretTag String?
backupCodes BackupCode[]
rememberedDevices RememberedDevice[]
auditLogs AuditLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model BackupCode {
id String @id @default(cuid())
userId String
codeHash String
usedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, usedAt])
@@unique([userId, codeHash])
}
model RememberedDevice {
id String @id @default(cuid())
userId String
deviceHash String
userAgent String?
expiresAt DateTime
lastUsedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, expiresAt])
@@unique([userId, deviceHash])
}
model AuditLog {
id String @id @default(cuid())
userId String?
action String
metadata Json?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([userId, createdAt])
@@index([action, createdAt])
}
# .env.example
DATABASE_URL="postgresql://user:password@localhost:5432/app"
TWO_FACTOR_ENCRYPTION_KEY="base64-encoded-32-byte-key"
TWO_FACTOR_HASH_SECRET="long-random-hmac-secret"
Crypto, Hashing und Rate Limit
// src/lib/two-factor.ts
import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { authenticator } from "otplib";
authenticator.options = { step: 30, window: 1 };
const buckets = new Map<string, { count: number; resetAt: number }>();
function encryptionKey() {
const key = Buffer.from(process.env.TWO_FACTOR_ENCRYPTION_KEY ?? "", "base64");
if (key.length !== 32) throw new Error("TWO_FACTOR_ENCRYPTION_KEY must be 32 bytes in base64.");
return key;
}
function hmacSecret() {
const secret = process.env.TWO_FACTOR_HASH_SECRET;
if (!secret || secret.length < 32) throw new Error("TWO_FACTOR_HASH_SECRET is too short.");
return secret;
}
export function encryptSecret(secret: string) {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", encryptionKey(), iv);
const ciphertext = Buffer.concat([cipher.update(secret, "utf8"), cipher.final()]);
return { ciphertext: ciphertext.toString("base64"), iv: iv.toString("base64"), tag: cipher.getAuthTag().toString("base64") };
}
export function decryptSecret(input: { ciphertext: string; iv: string; tag: string }) {
const decipher = createDecipheriv("aes-256-gcm", encryptionKey(), Buffer.from(input.iv, "base64"));
decipher.setAuthTag(Buffer.from(input.tag, "base64"));
return Buffer.concat([decipher.update(Buffer.from(input.ciphertext, "base64")), decipher.final()]).toString("utf8");
}
export function verifyTotp(token: string, secret: string) {
return /^\d{6}$/.test(token) && authenticator.verify({ token, secret });
}
export function generateBackupCodes(count = 10) {
return Array.from({ length: count }, () => {
const raw = randomBytes(5).toString("hex").toUpperCase();
return `${raw.slice(0, 5)}-${raw.slice(5)}`;
});
}
export function hashBackupCode(userId: string, code: string) {
const normalized = code.replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
return createHmac("sha256", hmacSecret()).update(`backup:${userId}:${normalized}`).digest("hex");
}
export function hashRememberedDevice(userId: string, token: string) {
return createHmac("sha256", hmacSecret()).update(`remember:${userId}:${token}`).digest("hex");
}
export function safeEqualHex(a: string, b: string) {
const left = Buffer.from(a, "hex");
const right = Buffer.from(b, "hex");
return left.length === right.length && timingSafeEqual(left, right);
}
export function consumeRateLimit(key: string, limit: number, windowMs: number) {
const now = Date.now();
const bucket = buckets.get(key);
if (!bucket || bucket.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + windowMs });
return true;
}
bucket.count += 1;
return bucket.count <= limit;
}
Das Map-Limit ist nur für lokale Entwicklung geeignet. In Produktion sollte Claude Code es mit Redis oder Upstash ersetzen, aber die gleiche Signatur behalten.
Enable-API und Backup-Codes
Setup erzeugt Secret und QR-Code, aktiviert aber nicht sofort. Erst ein gültiger TOTP schaltet 2FA ein und erzeugt Backup-Codes, die nur einmal angezeigt werden.
// src/app/api/account/2fa/enable/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/require-user";
import { decryptSecret, generateBackupCodes, hashBackupCode, consumeRateLimit, verifyTotp } from "@/lib/two-factor";
const schema = z.object({ token: z.string().regex(/^\d{6}$/) });
export async function POST(request: NextRequest) {
const user = await requireUser();
const { token } = schema.parse(await request.json());
if (!consumeRateLimit(`2fa-enable:${user.id}`, 5, 10 * 60 * 1000)) {
return NextResponse.json({ error: "Too many attempts." }, { status: 429 });
}
const record = await prisma.user.findUniqueOrThrow({ where: { id: user.id } });
if (!record.twoFactorSecretCiphertext || !record.twoFactorSecretIv || !record.twoFactorSecretTag) {
return NextResponse.json({ error: "2FA setup has not started." }, { status: 400 });
}
const secret = decryptSecret({
ciphertext: record.twoFactorSecretCiphertext,
iv: record.twoFactorSecretIv,
tag: record.twoFactorSecretTag,
});
if (!verifyTotp(token, secret)) {
await prisma.auditLog.create({ data: { userId: user.id, action: "2fa.enable.failed" } });
return NextResponse.json({ error: "Invalid code." }, { status: 400 });
}
const backupCodes = generateBackupCodes();
await prisma.$transaction([
prisma.backupCode.deleteMany({ where: { userId: user.id } }),
prisma.user.update({ where: { id: user.id }, data: { twoFactorEnabled: true, twoFactorEnabledAt: new Date() } }),
prisma.backupCode.createMany({ data: backupCodes.map((code) => ({ userId: user.id, codeHash: hashBackupCode(user.id, code) })) }),
prisma.auditLog.create({ data: { userId: user.id, action: "2fa.enabled", metadata: { backupCodeCount: backupCodes.length } } }),
]);
return NextResponse.json({ backupCodes });
}
Beim Login akzeptieren Sie TOTP oder ungenutzten Backup-Code. Ein erfolgreicher Backup-Code bekommt sofort usedAt. Für Remembered Device erzeugen Sie ein zufälliges Token, speichern nur den HMAC und senden das echte Token als httpOnly, secure, sameSite Cookie mit Ablauf und Widerrufsmöglichkeit.
UI-Komponente
// src/components/TwoFactorSetupPanel.tsx
"use client";
import { useState } from "react";
export function TwoFactorSetupPanel() {
const [qrCodeDataUrl, setQrCodeDataUrl] = useState("");
const [manualKey, setManualKey] = useState("");
const [token, setToken] = useState("");
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [error, setError] = useState("");
async function startSetup() {
const response = await fetch("/api/account/2fa/setup", { method: "POST" });
const data = await response.json();
if (!response.ok) return setError(data.error ?? "Setup failed.");
setQrCodeDataUrl(data.qrCodeDataUrl);
setManualKey(data.manualKey);
}
async function enable() {
const response = await fetch("/api/account/2fa/enable", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
const data = await response.json();
if (!response.ok) return setError(data.error ?? "Invalid code.");
setBackupCodes(data.backupCodes);
}
return (
<section aria-label="Two-factor authentication">
{!qrCodeDataUrl && <button onClick={startSetup}>Set up 2FA</button>}
{qrCodeDataUrl && backupCodes.length === 0 && (
<>
<img src={qrCodeDataUrl} alt="Authenticator app QR code" width={220} height={220} />
<p>Manual key: <code>{manualKey}</code></p>
<input inputMode="numeric" autoComplete="one-time-code" value={token} onChange={(event) => setToken(event.target.value)} maxLength={6} />
<button onClick={enable}>Verify and enable</button>
</>
)}
{backupCodes.length > 0 && (
<ul>{backupCodes.map((code) => <li key={code}><code>{code}</code></li>)}</ul>
)}
{error && <p role="alert">{error}</p>}
</section>
);
}
Recovery, SMS und Passkeys
Recovery ist oft der schwächste Teil. Nutzen Sie zuerst Backup-Codes. Wenn alle Faktoren verloren sind, kombinieren Sie E-Mail-Bestätigung, bestehende vertrauenswürdige Session, Owner-Freigabe im B2B-Fall und Wartezeit vor kritischen Aktionen. Danach alte Sessions widerrufen und Nutzer informieren.
SMS ist manchmal besser als gar kein Faktor, bleibt aber anfällig für SIM-Swap, Nummern-Recycling und Social Engineering beim Provider. Für Admins, Zahlungen und API-Keys sollte SMS nicht der primäre Faktor sein.
WebAuthn/passkeys sind langfristig stärker, weil sie Public-Key-Credentials an den Origin binden. Starten Sie mit sauberem TOTP und ergänzen Sie später eine WebAuthn-Credential-Tabelle mit denselben Audit-, Widerrufs- und Step-Up-Regeln.
// src/lib/step-up.ts
export function assertFreshMfa(session: { mfaAt?: Date | null }, maxAgeMinutes = 15) {
if (!session.mfaAt) throw new Error("MFA is required.");
if (Date.now() - session.mfaAt.getTime() > maxAgeMinutes * 60 * 1000) {
throw new Error("MFA challenge is too old.");
}
}
Praxisfälle und Stolperfallen
Fall 1: Admin-Einladung. Vor Owner- oder Admin-Einladungen muss mfaAt frisch sein. Eine gestohlene Session darf nicht reichen.
Fall 2: Billing. Kartenänderung, Rechnungs-E-Mail, Rückerstattung und Steuerdaten brauchen Step-Up Auth. Auditieren Sie das Ereignis, nicht Kartendaten oder Backup-Codes.
Fall 3: API-Key-Erstellung. Neue Keys oder größere Scopes brauchen MFA und RBAC, zum Beispiel api_key:create.
Fall 4: Support-Recovery. Support erstellt eine Anfrage, deaktiviert MFA aber nicht direkt. Zwei-Personen-Freigabe, Benachrichtigung, Wartezeit und Audit sind Pflicht.
Typische Fehler sind Klartext-Secrets, wiederverwendbare Backup-Codes, kein Rate Limit, rohe Remembered-Device-Tokens, Deaktivierung ohne Step-Up, zu lange Recovery-Links und Logs mit Secrets.
Abschluss
Gute 2FA ist Session Assurance, Recovery, Audit und Betrieb in einem. Claude Code hilft, wenn die Aufgaben klein und prüfbar bleiben. ClaudeCodeLab kann bestehende MFA-Flows prüfen, Teams in sicheren Claude-Code-Workflows schulen oder eine Next.js-Roadmap von TOTP zu passkeys umsetzen.
Beim Testen prüfen Sie getrennte Schlüssel pro Umgebung, einmalige Anzeige der Backup-Codes, Rate Limit bei TOTP-Fehlern, widerrufbare httpOnly Cookies für Remembered Device, Step-Up beim Deaktivieren von 2FA und Audit-Logs ohne geheime Werte.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Workflow von Obsidian zu CLAUDE.md
Obsidian-Arbeitsnotizen in CLAUDE.md-Betriebsnotizen verwandeln und Kontext nicht ständig neu erklären.
Claude Code Revenue CTA Routing: Artikel zu PDF, Gumroad und Beratung führen
Ein Claude-Code-Ablauf, der Leser nach Absicht zu Gratis-PDF, Gumroad oder Beratung führt.
Claude-Code-Team-Handoff-Regeln: Belege, Berechtigungen, Rollback und Umsatzpfade
Ein praktisches Claude-Code-Handoff für Review-Belege, Berechtigungen, Rollback, Gratis-PDF, Gumroad und Beratung.