Claude Code로 안전한 비밀번호 재설정 구현하기
Claude Code로 안전한 비밀번호 재설정을 구현합니다. 랜덤 토큰, 해시 저장, rate limit, 메일, 세션 무효화, MFA 분리를 다룹니다.
비밀번호 재설정은 단순한 편의 기능이 아니라 계정 복구 경로입니다. 로그인 화면을 아무리 강하게 만들어도, 재설정 링크가 약하면 공격자는 그쪽을 사용합니다. 흔한 사고는 이메일 존재 여부 노출, 원본 토큰 저장, 만료되지 않는 링크, 재사용 가능한 토큰, 재설정 후에도 살아 있는 기존 세션, 그리고 MFA 복구를 비밀번호 재설정과 섞는 설계에서 시작됩니다.
Claude Code는 이 기능을 빠르게 만들 수 있지만, 지시가 모호하면 “동작은 하지만 위험한” 구현이 나오기 쉽습니다. 따라서 처음부터 사용자 존재를 새지 않게 하는 응답, 랜덤 토큰, 토큰 해시 저장, 짧은 유효기간, 1회 사용, rate limit, 메일 템플릿, Referrer-Policy, noindex, 세션 무효화, 감사 로그, MFA 재설정 분리를 수용 조건으로 적어야 합니다.
이 글은 OWASP의 Forgot Password Cheat Sheet, Authentication Cheat Sheet, Testing for Weak Password Change or Reset Functionalities를 기준으로 작성했습니다. 전체 보안 관점은 Claude Code 보안 베스트 프랙티스와 Claude Code 환경 변수 관리도 함께 보세요.
전체 흐름
reset token은 임시 열쇠입니다. 사용자는 이메일로 이 열쇠를 받지만, DB에는 원본 열쇠를 저장하지 않습니다. 해시만 저장하면 DB 백업이나 읽기 권한이 유출되어도 바로 재설정 링크를 만들 수 없습니다.
flowchart TD
A[사용자가 이메일 입력] --> B[항상 같은 안내 메시지 반환]
B --> C{rate limit 통과}
C -- no --> Z[계정 존재 여부를 노출하지 않음]
C -- yes --> D{사용자가 존재함}
D -- no --> Z
D -- yes --> E[Node crypto로 랜덤 토큰 생성]
E --> F[SHA-256 토큰 해시만 저장]
F --> G[HTTPS 재설정 링크를 이메일로 전송]
G --> H[noindex와 Referrer-Policy가 있는 재설정 페이지]
H --> I[새 비밀번호 제출]
I --> J[트랜잭션에서 토큰 1회 소비]
J --> K[비밀번호 해시 저장 및 세션 무효화]
K --> L[감사 로그 기록 후 다시 로그인 요구]
| 항목 | 권장 방식 | 이유 |
|---|---|---|
| 사용자 존재 여부 | 존재/미존재 이메일 모두 같은 응답 | 이메일 열거 방지 |
| 토큰 생성 | crypto.randomBytes(32).toString("base64url") | 추측하기 어려운 값 생성 |
| 토큰 저장 | SHA-256 해시만 저장 | DB 유출 시 원본 링크 보호 |
| 유효기간 | 30분 | 메일 지연과 위험 시간을 균형 있게 처리 |
| 사용 횟수 | 1회만 허용 | 전달, 히스토리, 로그 재사용 방지 |
| rate limit | IP와 이메일 기준 | 메일 폭탄과 무차별 시도 완화 |
| 메일 | 비밀번호는 절대 포함하지 않음 | 이메일은 비밀 저장소가 아님 |
| 재설정 페이지 | noindex, Referrer-Policy: no-referrer | 검색 노출과 Referer 토큰 유출 방지 |
| 재설정 후 | 기존 세션 무효화, 자동 로그인 금지 | 탈취된 세션 정리 |
| MFA | 이 플로우에서 MFA를 재설정하지 않음 | MFA 복구는 더 강한 본인 확인이 필요 |
Claude Code에 맡길 작업 단위
보안 기능은 작은 단위로 나눠야 리뷰가 가능합니다. 다음 순서가 실무에서 가장 안전합니다.
- Prisma schema에
PasswordResetToken,Session,AuditLog를 추가합니다. - Node crypto 기반 서비스 계층을 만들고, 토큰 해시 저장, 만료, 1회 사용, rate limit을 구현합니다.
- Next.js App Router의 request API와 confirm API를 구현합니다.
- 메일 템플릿, reset 페이지,
Referrer-Policy,X-Robots-Tag, 캐시 헤더를 추가합니다. - 이메일 열거 방지, 만료 토큰, 사용済み 토큰, session revoke 테스트를 작성합니다.
Claude Code에는 이렇게 지시합니다.
Next.js App Router + TypeScript + Prisma 앱에 비밀번호 재설정을 구현하세요.
제약:
- 존재하는 이메일과 존재하지 않는 이메일은 같은 JSON과 비슷한 응답 시간을 반환한다
- reset token은 Node crypto로 32 bytes 이상 생성하고 DB에는 SHA-256 hash만 저장한다
- token은 30분 후 만료되고 1회만 사용할 수 있다
- request API는 IP와 정규화된 이메일 기준으로 rate limit한다
- reset page에는 noindex와 Referrer-Policy no-referrer를 설정한다
- 성공 후 모든 기존 session을 revoke하고 자동 로그인하지 않는다
- MFA reset은 구현하지 않는다. MFA 복구는 별도 플로우로 둔다
- 원본 token, reset URL, password를 로그에 남기지 않는다
인증, Prisma, 메일, 테스트에 필요한 파일만 변경하세요.
Prisma schema
아래 모델은 최소 예시입니다. 이미 User나 Session이 있다면 기존 모델에 맞춰 병합하세요.
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
mfaEnabled Boolean @default(false)
passwordChangedAt DateTime?
createdAt DateTime @default(now())
resetTokens PasswordResetToken[]
sessions Session[]
auditLogs AuditLog[]
}
model PasswordResetToken {
id String @id @default(cuid())
userId String
tokenHash String @unique
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
requestedIp String?
requestedUserAgent String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, expiresAt])
@@index([createdAt])
}
model Session {
id String @id @default(cuid())
userId String
revokedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, revokedAt])
}
model AuditLog {
id String @id @default(cuid())
userId String?
event String
ip String?
userAgent String?
metadata Json?
createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([userId, createdAt])
@@index([event, createdAt])
}
서비스 계층 구현
다음 코드는 작은 Next.js 앱에서 바로 붙여 넣어 검증할 수 있는 수준입니다. @prisma/client, bcryptjs, zod를 설치하세요. 운영 환경에서는 메모리 Map rate limit을 Redis 같은 공유 저장소로 바꿔야 합니다.
// src/lib/password-reset.ts
import crypto from "node:crypto";
import bcrypt from "bcryptjs";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const RESET_TTL_MINUTES = 30;
const TOKEN_BYTES = 32;
const MIN_RESPONSE_MS = 450;
export const REQUEST_RESPONSE = {
message: "If an account exists for that email, a password reset link has been sent.",
};
const memoryLimits = new Map<string, { count: number; resetAt: number }>();
function normalizeEmail(email: string) {
return email.trim().toLowerCase();
}
function hashToken(token: string) {
return crypto.createHash("sha256").update(token, "utf8").digest("hex");
}
function appOrigin() {
const origin = process.env.APP_ORIGIN;
if (!origin || !origin.startsWith("https://")) {
throw new Error("APP_ORIGIN must be an HTTPS origin");
}
return origin.replace(/\/$/, "");
}
function isLimited(key: string, max: number, windowMs: number) {
const now = Date.now();
const current = memoryLimits.get(key);
if (!current || current.resetAt <= now) {
memoryLimits.set(key, { count: 1, resetAt: now + windowMs });
return false;
}
current.count += 1;
return current.count > max;
}
async function padResponse(startedAt: number) {
const elapsed = Date.now() - startedAt;
if (elapsed < MIN_RESPONSE_MS) {
await new Promise((resolve) => setTimeout(resolve, MIN_RESPONSE_MS - elapsed));
}
}
async function sendPasswordResetEmail(input: { to: string; resetUrl: string }) {
const html = `
<div style="font-family:Arial,sans-serif;line-height:1.6;color:#111827">
<h1 style="font-size:20px">Reset your password</h1>
<p>This link expires in 30 minutes. If you did not request it, ignore this email.</p>
<p><a href="${input.resetUrl}" style="display:inline-block;background:#2563eb;color:#fff;padding:12px 18px;border-radius:6px;text-decoration:none">Reset password</a></p>
</div>
`;
if (!process.env.RESEND_API_KEY) {
console.info("Password reset email skipped locally", { to: input.to, resetUrl: input.resetUrl });
return;
}
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: process.env.MAIL_FROM ?? "support@example.com",
to: input.to,
subject: "Reset your password",
html,
}),
});
if (!response.ok) throw new Error(`Failed to send reset email: ${response.status}`);
}
export async function requestPasswordReset(input: { email: string; ip?: string; userAgent?: string }) {
const startedAt = Date.now();
const email = normalizeEmail(input.email);
const ip = input.ip ?? "unknown";
const limited =
isLimited(`password-reset:ip:${ip}`, 20, 60 * 60 * 1000) ||
isLimited(`password-reset:email:${email}`, 5, 15 * 60 * 1000);
if (limited) {
await padResponse(startedAt);
return REQUEST_RESPONSE;
}
const user = await prisma.user.findUnique({ where: { email }, select: { id: true, email: true } });
if (user) {
const token = crypto.randomBytes(TOKEN_BYTES).toString("base64url");
const expiresAt = new Date(Date.now() + RESET_TTL_MINUTES * 60 * 1000);
await prisma.passwordResetToken.create({
data: {
userId: user.id,
tokenHash: hashToken(token),
expiresAt,
requestedIp: ip,
requestedUserAgent: input.userAgent?.slice(0, 300),
},
});
await prisma.auditLog.create({
data: {
userId: user.id,
event: "password_reset_requested",
ip,
userAgent: input.userAgent?.slice(0, 300),
metadata: { expiresAt: expiresAt.toISOString() },
},
});
await sendPasswordResetEmail({
to: user.email,
resetUrl: `${appOrigin()}/reset-password?token=${encodeURIComponent(token)}`,
});
}
await padResponse(startedAt);
return REQUEST_RESPONSE;
}
export async function resetPassword(input: { token: string; newPassword: string; ip?: string; userAgent?: string }) {
if (input.newPassword.length < 12 || input.newPassword.length > 128) {
throw new Error("invalid_password");
}
const tokenHash = hashToken(input.token);
const now = new Date();
const passwordHash = await bcrypt.hash(input.newPassword, 12);
return prisma.$transaction(async (tx) => {
const resetToken = await tx.passwordResetToken.findFirst({
where: { tokenHash, expiresAt: { gt: now }, usedAt: null },
select: { id: true, userId: true },
});
if (!resetToken) throw new Error("invalid_or_expired_token");
const claimed = await tx.passwordResetToken.updateMany({
where: { id: resetToken.id, usedAt: null },
data: { usedAt: now },
});
if (claimed.count !== 1) throw new Error("invalid_or_expired_token");
await tx.user.update({
where: { id: resetToken.userId },
data: { passwordHash, passwordChangedAt: now },
});
await tx.session.updateMany({
where: { userId: resetToken.userId, revokedAt: null },
data: { revokedAt: now },
});
await tx.auditLog.create({
data: {
userId: resetToken.userId,
event: "password_reset_completed",
ip: input.ip,
userAgent: input.userAgent?.slice(0, 300),
metadata: { sessionsRevoked: true },
},
});
return { message: "Password updated. Please sign in again." };
});
}
API route와 헤더
request API는 이메일이 존재하지 않아도 같은 JSON과 같은 상태 코드를 반환합니다. confirm API는 링크가 유효하지 않다는 정보만 제공합니다.
// src/app/api/auth/password-reset/request/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { REQUEST_RESPONSE, requestPasswordReset } from "@/lib/password-reset";
const bodySchema = z.object({ email: z.string().email().max(320) });
function clientIp(request: Request) {
return request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
}
export async function POST(request: Request) {
const parsed = bodySchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json(REQUEST_RESPONSE, { status: 202 });
await requestPasswordReset({
email: parsed.data.email,
ip: clientIp(request),
userAgent: request.headers.get("user-agent") ?? undefined,
});
return NextResponse.json(REQUEST_RESPONSE, { status: 202 });
}
// src/app/api/auth/password-reset/confirm/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { resetPassword } from "@/lib/password-reset";
const bodySchema = z
.object({
token: z.string().min(32).max(256),
password: z.string().min(12).max(128),
confirmPassword: z.string().min(12).max(128),
})
.refine((value) => value.password === value.confirmPassword);
export async function POST(request: Request) {
const parsed = bodySchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ message: "The reset link is invalid or the password policy was not met." }, { status: 400 });
}
try {
const result = await resetPassword({
token: parsed.data.token,
newPassword: parsed.data.password,
userAgent: request.headers.get("user-agent") ?? undefined,
});
return NextResponse.json(result, { status: 200 });
} catch {
return NextResponse.json({ message: "The reset link is invalid or expired." }, { status: 400 });
}
}
// next.config.mjs
const nextConfig = {
async headers() {
return [
{
source: "/reset-password",
headers: [
{ key: "Referrer-Policy", value: "no-referrer" },
{ key: "X-Robots-Tag", value: "noindex, nofollow" },
{ key: "Cache-Control", value: "no-store" },
],
},
];
},
};
export default nextConfig;
실제 사용 사례
첫째, 일반 SaaS 사용자가 비밀번호를 잊은 경우입니다. 30분 만료, 1회 사용, 기존 세션 무효화가 있으면 예전 휴대폰이나 공유 브라우저에 남은 세션을 정리할 수 있습니다.
둘째, 관리자 계정입니다. 관리자는 비밀번호 재설정 후에도 일반 로그인과 MFA를 다시 통과해야 합니다. MFA 기기를 잃어버린 상황은 2단계 인증 구현처럼 별도 복구 플로우로 설계해야 합니다.
셋째, 고객지원 문의입니다. “메일이 오지 않았다”는 문의가 오면 감사 로그에서 요청 시각, IP, User-Agent, 메일 발송 상태를 확인할 수 있어야 합니다. 하지만 원본 토큰은 보이면 안 됩니다.
넷째, B2B 테넌트에 reset 메일 폭탄이 들어오는 경우입니다. IP와 이메일 기준 rate limit을 두면 지원 큐가 터지기 전에 이상 징후를 볼 수 있습니다.
실패 사례와 리뷰 포인트
가장 위험한 실패는 이 이메일은 등록되어 있지 않습니다라고 알려주는 것입니다. 이것은 곧 계정 목록 생성 API가 됩니다. 존재하지 않는 이메일만 빠르게 끝나는 응답 시간 차이도 같은 문제입니다.
두 번째는 원본 토큰 저장입니다. DB, 로그, 에러 추적, 고객지원 도구에 토큰이 남으면 읽기 권한이 계정 탈취 권한으로 바뀝니다.
세 번째는 만료되지 않거나 여러 번 쓸 수 있는 토큰입니다. 이메일은 전달되고 브라우저 히스토리는 동기화됩니다. usedAt을 트랜잭션 안에서 기록해 첫 번째 요청만 성공하게 해야 합니다.
네 번째는 요청의 Host header로 reset URL을 만드는 것입니다. 고정된 HTTPS APP_ORIGIN을 사용하고, 환경 변수 리뷰 대상으로 관리하세요.
Claude Code 출력 리뷰에서는 동일 응답, 토큰 해시 저장, expiresAt과 usedAt, session revoke, 자동 로그인 금지, reset 페이지 헤더, MFA 분리, 감사 로그를 반드시 확인합니다. 본番 키와 본番 DB 접속정보를 Claude Code 작업 환경에 넘기지 않는 것도 중요합니다.
npm install @prisma/client bcryptjs zod
npx prisma migrate dev --name add_password_reset
npm run lint
npm test
마무리
비밀번호 재설정은 작은 인증 시스템입니다. Claude Code에 맡길 때는 폼이 아니라 보안 요구사항 전체를 맡겨야 합니다. 사용자 존재 비노출, 랜덤 토큰, 해시 저장, 짧은 만료, 1회 사용, rate limit, 안전한 이메일, noindex, Referrer-Policy, 세션 무효화, 감사 로그, MFA 분리를 명확한 완료 조건으로 두세요.
ClaudeCodeLab은 Claude Code 기반 인증 구현 리뷰, 교육, 구현 지원을 제공합니다. 현재 로그인 흐름, 세션 모델, 메일 제공자, MFA 정책을 준비하면 어떤 부분을 Claude Code에 맡기고 어떤 부분을 사람이 리뷰해야 하는지 빠르게 정리할 수 있습니다.
이 글의 구현을 실제로 시험할 때는 네 가지를 먼저 확인하세요. 실제 이메일과 없는 이메일의 응답이 같은지, DB에 원본 토큰이 없는지, 만료 및 사용済み 토큰이 실패하는지, 재설정 후 예전 세션이 보호 API에 접근하지 못하는지입니다. Masa가 구현 지원을 할 때도 이 네 가지를 손으로 먼저 확인한 뒤 자동화 테스트로 옮깁니다.
무료 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, 상담 경로 체크리스트.