Use Cases (Diperbarui: 1/6/2026)

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.

Menerapkan 2FA dengan Claude Code: TOTP, Backup Code, Recovery, dan Step-Up Auth

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.

#Claude Code #2FA #MFA #TOTP #keamanan
Gratis

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.

Masa

Tentang penulis

Masa

Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.