Claude Code로 2FA 구현하기: TOTP, 백업 코드, 복구, Step-Up 인증
Next.js에서 Claude Code로 안전한 2FA/MFA를 구현하는 방법. TOTP, 백업 코드, 복구, 감사 로그까지 다룹니다.
2FA는 QR 코드를 보여주는 화면 하나로 끝나는 기능이 아닙니다. 운영 환경에 넣을 2FA/MFA라면 TOTP, 백업 코드, 민감한 작업을 위한 step-up auth, 복구 절차, 실패 횟수 제한, 기억된 기기, 감사 로그, 그리고 WebAuthn/passkeys로 확장할 수 있는 구조까지 함께 설계해야 합니다.
이 글은 Next.js App Router, TypeScript, Prisma를 기준으로 Claude Code에게 어떤 단위로 작업을 맡기고, 어떤 기준으로 리뷰해야 하는지 설명합니다. 로그인 기반은 Claude Code JWT 인증 가이드, 비밀번호 복구는 Claude Code 비밀번호 재설정 구현, 권한 경계는 Claude Code RBAC 구현 가이드, 에이전트 권한 관리는 Claude Code 보안 베스트 프랙티스와 함께 보는 것이 좋습니다.
외부 기준으로는 OWASP MFA Cheat Sheet, OWASP WSTG MFA 테스트, MDN Web Authentication API, W3C WebAuthn Level 3를 참고합니다. OWASP는 MFA를 언제 요구할지, OTP를 어떻게 보관할지, 실패 시도를 어떻게 제한할지, 복구와 인증 요소 변경을 어떻게 다룰지까지 확인하라고 권장합니다.
먼저 보안 경계를 정한다
TOTP는 time-based one-time password의 약자입니다. 서버와 인증 앱이 같은 secret을 갖고, 보통 30초마다 6자리 코드를 만듭니다. MFA는 세션의 신뢰도를 높이는 장치이지, 권한 부여를 대신하지 않습니다. 관리자 초대, 결제 정보 변경, API 키 발급, 비밀번호 변경, 개인정보 내보내기 같은 작업에는 로그인 상태여도 새 MFA 확인을 요구하는 step-up auth가 필요합니다.
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
Claude Code에는 이 경계부터 전달합니다. “2FA를 구현해줘”라고 크게 맡기지 말고, 데이터베이스 스키마, 암호화 헬퍼, setup API, enable API, 로그인 챌린지, 복구 플로우, remembered device, 감사 로그, 테스트로 쪼개는 편이 안전합니다. 운영 키, KMS, 고객지원 정책, 관리자 권한은 사람이 결정해야 합니다.
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.
리뷰에서는 TOTP secret 평문 저장, 백업 코드 평문 저장 또는 로그 출력, 백업 코드 재사용 가능성, 6자리 코드에 대한 rate limit 부재, remembered device token 원문 저장, 고객지원 담당자 1명이 MFA를 즉시 해제할 수 있는 흐름, enable/disable/failed/recovery/factor-change 감사 로그 누락을 집중적으로 봅니다.
데이터 모델
TOTP secret은 나중에 검증할 때 복호화해야 하므로 암호화해서 저장합니다. 백업 코드와 remembered device token은 비교만 하면 되므로 해시만 저장합니다.
// 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"
암호화, 해시, rate limit
아래 코드는 TOTP secret을 AES-256-GCM으로 암호화하고, 백업 코드와 remembered device token을 HMAC-SHA256으로 해시합니다. 데이터베이스가 유출되어도 공격자가 바로 인증 코드를 만들거나 신뢰 기기를 위조하지 못하게 하는 것이 목표입니다.
// 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;
}
이 Map 기반 rate limit은 로컬 개발용입니다. 여러 인스턴스나 서버리스 환경에서는 Claude Code에게 같은 함수 시그니처를 유지한 채 Redis 또는 Upstash 구현으로 바꾸게 하세요.
Enable API와 백업 코드
setup API는 secret과 QR 코드를 만들지만 곧바로 2FA를 켜지 않습니다. 사용자가 인증 앱으로 읽고 첫 TOTP를 입력해 성공한 뒤에야 enable API가 백업 코드를 만들고 2FA를 활성화합니다.
// 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 });
}
로그인 챌린지는 TOTP 또는 사용하지 않은 백업 코드를 받아야 합니다. 백업 코드가 성공하면 같은 요청에서 usedAt을 채웁니다. remembered device는 무작위 token을 만들고 HMAC 결과만 DB에 저장하며, 원본 token은 httpOnly, secure, sameSite cookie로 짧은 만료 시간과 함께 내려보냅니다.
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>
);
}
복구, SMS, Passkeys
복구 플로우는 MFA의 가장 약한 지점이 되기 쉽습니다. 먼저 백업 코드를 쓰게 하고, 모든 요소를 잃은 경우에는 이메일 확인, 기존 신뢰 세션, B2B의 조직 owner 승인, 복구 후 대기 시간을 조합합니다. 복구가 끝나면 사용자에게 알리고 기존 세션을 폐기합니다.
SMS는 소비자 서비스에서 “없는 것보다는 나은” 경우가 있지만, SIM swap, 번호 재사용, 통신사 고객센터를 이용한 사회공학 공격에 약합니다. 관리자, 결제, API 키에는 기본 요소로 두지 않는 편이 안전합니다.
WebAuthn/passkeys는 origin에 묶인 공개키 자격 증명을 쓰므로 피싱에 더 강합니다. 처음에는 TOTP와 백업 코드를 단단히 만들고, 나중에 WebAuthn credential 테이블을 추가하면서 같은 감사, 폐기, step-up 정책을 재사용하면 됩니다.
// 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.");
}
}
실제 사용 사례와 실패 패턴
사용 사례 1은 관리자 초대입니다. owner나 admin을 초대하기 전 15분 이내의 mfaAt을 요구하면, 세션 cookie가 탈취되어도 조직 장악을 막을 수 있습니다.
사용 사례 2는 결제 정보 변경입니다. 카드 변경, 청구 이메일 변경, 환불, 세금 정보 수정에는 step-up auth를 요구하고, 카드 번호나 백업 코드가 아닌 이벤트와 메타데이터만 감사 로그에 남깁니다.
사용 사례 3은 API 키 발급입니다. API 키를 만들거나 scope를 넓힐 때 MFA를 요구하고, RBAC로 api_key:create 권한을 가진 사용자만 통과하게 합니다.
사용 사례 4는 고객지원 복구입니다. 담당자가 즉시 MFA를 끄는 것이 아니라 복구 요청을 만들고, 2인 승인, 사용자 알림, 대기 시간, 감사 로그를 거쳐 처리해야 합니다.
흔한 실패는 TOTP secret 평문 저장, 백업 코드 재사용, 6자리 코드 무제한 시도, remembered device token 원문 저장, MFA 비활성화에 step-up이 없는 것, 복구 링크의 긴 유효기간, 감사 로그에 secret이 남는 것입니다. Claude Code가 코드를 만들었다면 OWASP MFA Cheat Sheet와 WSTG 관점으로 다시 비판적으로 리뷰하게 하세요.
마무리
안전한 2FA는 단일 기능이 아니라 세션 신뢰도, 복구, 감사, 운영 정책을 묶은 설계입니다. Claude Code의 장점은 작은 작업을 빠르게 구현하고 테스트를 붙이는 데 있습니다. 대신 암호화, 해시, rate limit, 복구 승인, passkeys 확장성 같은 경계는 사람이 계속 확인해야 합니다. ClaudeCodeLab은 기존 MFA 흐름 리뷰, Claude Code 보안 개발 교육, Next.js 제품의 TOTP에서 passkeys로의 전환 설계를 지원할 수 있습니다.
이 글의 구현을 실제로 시험할 때는 환경별 암호화 키 분리, 백업 코드 1회 표시, TOTP 실패 rate limit, remembered device cookie의 httpOnly 설정과 폐기 가능성, 2FA 비활성화 시 step-up 요구, 감사 로그에 secret이 남지 않는지를 확인하세요.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.