Menerapkan 2FA dengan Claude Code: TOTP, Backup Code, Recovery, dan Step-Up Auth
Panduan 2FA/MFA di Next.js dengan Claude Code: TOTP, backup code, recovery, rate limit, passkeys, dan audit log.
2FA bukan sekadar halaman yang menampilkan QR code. Implementasi 2FA/MFA yang layak produksi perlu menangani TOTP, backup code, step-up auth untuk aksi sensitif, recovery akun, rate limit, remembered device, audit log, dan jalur menuju WebAuthn/passkeys.
Artikel ini memakai konteks Next.js App Router, TypeScript, dan Prisma. Fokusnya bukan hanya membuat Claude Code menulis kode cepat, tetapi membagi pekerjaan agar bisa direview. Untuk login dasar, lihat panduan JWT authentication dengan Claude Code. Untuk reset password, lihat panduan password reset. Untuk permission, lihat panduan RBAC. Untuk batas izin agent, baca Claude Code security best practices.
Rujukan eksternal yang dipakai adalah OWASP MFA Cheat Sheet, OWASP WSTG untuk pengujian MFA, MDN Web Authentication API, dan W3C WebAuthn Level 3. Intinya, MFA juga mencakup penyimpanan OTP, percobaan gagal, recovery, dan perubahan faktor.
Tetapkan Batas Keamanan
TOTP berarti time-based one-time password. Server dan aplikasi authenticator berbagi secret, lalu membuat kode pendek, biasanya setiap 30 detik. MFA menaikkan assurance sesi, tetapi tidak menggantikan authorization. Undangan admin, perubahan billing, pembuatan API key, perubahan password, dan ekspor data pribadi harus meminta MFA yang masih segar.
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
Berikan batas ini kepada Claude Code sebelum meminta kode. Jangan memberi prompt besar seperti “implementasikan 2FA”. Pecah menjadi database schema, crypto helper, setup API, enable API, login challenge, recovery flow, remembered device, audit log, dan test. Secret production, KMS, kebijakan support, dan permission admin tetap harus diputuskan manusia.
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.
Saat review, cari masalah konkret: TOTP secret tersimpan plaintext, backup code tersimpan atau tercetak di log, backup code bisa dipakai ulang, kode 6 digit tidak punya rate limit, remembered device token disimpan mentah, satu orang support bisa mematikan MFA, atau tidak ada audit log untuk enable, disable, failure, recovery, dan factor change.
Model Data
TOTP secret dienkripsi karena server perlu membacanya lagi untuk verifikasi. Backup code dan remembered device token cukup dibandingkan, jadi yang disimpan adalah HMAC hash.
// 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"
Enkripsi, Hash, dan 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;
}
Limiter berbasis Map ini cukup untuk lokal. Di production, minta Claude Code menggantinya dengan Redis atau Upstash sambil mempertahankan signature fungsi agar route dan test tetap stabil.
Enable API dan Backup Code
Setup API membuat secret dan QR code, tetapi belum mengaktifkan 2FA. Aktivasi baru terjadi setelah user memasukkan TOTP pertama yang valid. Backup code dikembalikan satu kali saja.
// 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 });
}
Saat login, terima TOTP atau backup code yang belum dipakai. Jika backup code berhasil, isi usedAt pada request yang sama. Untuk remembered device, buat token acak, simpan hanya HMAC, lalu kirim token asli lewat cookie httpOnly, secure, sameSite dengan expiry pendek dan fitur revoke.
Komponen UI
// 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, dan Passkeys
Recovery sering menjadi titik terlemah MFA. Utamakan backup code. Jika semua faktor hilang, gabungkan konfirmasi email, sesi tepercaya yang masih ada, persetujuan owner untuk B2B, dan cooldown sebelum aksi berisiko tinggi. Setelah recovery, beri tahu user dan revoke sesi lama.
SMS kadang lebih baik daripada tidak ada faktor kedua, tetapi rentan SIM swap, daur ulang nomor, dan social engineering ke operator. Untuk admin, billing, dan API key, jangan jadikan SMS faktor utama.
WebAuthn/passkeys adalah arah jangka panjang yang lebih tahan phishing karena memakai public-key credentials yang terikat origin. Mulai dari TOTP yang rapi, lalu tambahkan tabel WebAuthn credential dengan aturan audit, revocation, dan step-up yang sama.
// 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.");
}
}
Contoh Kasus dan Kesalahan Umum
Kasus 1: undangan admin. Minta mfaAt yang masih fresh sebelum mengundang owner atau admin. Cookie sesi yang dicuri tidak boleh cukup untuk mengambil alih organisasi.
Kasus 2: perubahan billing. Perubahan kartu, email invoice, refund, dan data pajak perlu step-up auth. Audit event-nya, bukan nomor kartu atau backup code.
Kasus 3: pembuatan API key. Membuat key atau memperluas scope harus melewati MFA dan RBAC, misalnya permission api_key:create.
Kasus 4: recovery oleh support. Support membuat permintaan recovery, bukan langsung mematikan MFA. Gunakan persetujuan dua orang, notifikasi user, waktu tunggu, dan audit log.
Kesalahan umum: TOTP secret plaintext, backup code bisa dipakai ulang, kode 6 digit tanpa rate limit, remembered device token mentah di database, disable 2FA tanpa step-up, link recovery terlalu lama, dan log yang berisi secret.
Penutup
2FA yang kuat adalah desain session assurance, recovery, audit, dan operasi. Claude Code sangat berguna ketika tugas kecil dan bisa direview. ClaudeCodeLab dapat membantu mengaudit flow MFA yang sudah ada, melatih tim memakai Claude Code dengan aman, atau menyusun roadmap dari TOTP ke passkeys untuk produk Next.js.
Saat mencoba implementasi ini, cek key berbeda per environment, backup code hanya tampil sekali, kegagalan TOTP terkena rate limit, cookie remembered device httpOnly dan bisa dicabut, disable 2FA membutuhkan step-up auth, serta audit log tidak menyimpan nilai rahasia.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Workflow Obsidian ke CLAUDE.md untuk Claude Code
Ubah catatan kerja Obsidian menjadi operating note CLAUDE.md agar konteks tidak dijelaskan ulang.
Claude Code Revenue CTA Routing: dari artikel ke PDF, Gumroad, dan konsultasi
Workflow Claude Code untuk mengarahkan pembaca ke PDF gratis, Gumroad, atau konsultasi sesuai intent.
Aturan handoff tim Claude Code: bukti review, permission, rollback, dan jalur revenue
Format handoff Claude Code untuk tim: bukti, permission rule, rollback, PDF gratis, Gumroad, dan konsultasi.