Password Reset Aman dengan Claude Code: Next.js, Prisma, dan Token
Implementasi password reset aman dengan Claude Code: token acak, hash di database, rate limit, email, sesi, MFA, dan audit log.
Password reset bukan sekadar halaman bantuan. Ini adalah jalur pemulihan akun. Jika jalur ini lebih lemah daripada login, penyerang akan memakai reset flow, bukan mencoba menembus login. Kesalahan yang sering muncul adalah membocorkan apakah email terdaftar, menyimpan token mentah, membuat link terlalu lama berlaku, mengizinkan token dipakai berulang, membiarkan sesi lama tetap aktif, atau mencampur pemulihan MFA dengan reset password.
Claude Code bisa membuat fitur ini dengan cepat, tetapi instruksinya harus presisi. Jangan hanya meminta “buat halaman lupa password”. Minta workflow yang bisa direview: respons generik, token acak, penyimpanan hash token, masa berlaku pendek, sekali pakai, rate limit, template email, Referrer-Policy, noindex, pencabutan sesi, audit log, dan pemisahan MFA reset.
Artikel ini mengikuti panduan OWASP Forgot Password Cheat Sheet, Authentication Cheat Sheet, dan Testing for Weak Password Change or Reset Functionalities. Untuk konteks Claude Code yang lebih luas, baca juga praktik keamanan Claude Code dan manajemen environment Claude Code.
Alur yang aman
Reset token adalah kunci sementara. Pengguna menerima kunci ini lewat email, tetapi database tidak boleh menyimpan kunci mentah. Simpan hash saja supaya backup atau database leak tidak langsung menjadi link reset yang bisa dipakai.
flowchart TD
A[Pengguna mengirim email] --> B[API selalu mengembalikan pesan yang sama]
B --> C{rate limit ok}
C -- tidak --> Z[Tidak membocorkan apakah akun ada]
C -- ya --> D{Pengguna ada}
D -- tidak --> Z
D -- ya --> E[Buat token acak dengan Node crypto]
E --> F[Simpan hanya SHA-256 hash token]
F --> G[Kirim link HTTPS lewat email]
G --> H[Halaman reset dengan noindex dan Referrer-Policy]
H --> I[Pengguna mengirim password baru]
I --> J[Konsumsi token sekali dalam transaction]
J --> K[Hash password dan revoke sesi]
K --> L[Tulis audit log dan minta login ulang]
| Area | Keputusan | Alasan |
|---|---|---|
| Keberadaan pengguna | Email ada dan tidak ada mendapat respons yang sama | Mencegah account enumeration |
| Token | crypto.randomBytes(32).toString("base64url") | Sulit ditebak |
| Penyimpanan | Simpan SHA-256 hash saja | DB leak tidak membuka link reset |
| Masa berlaku | 30 menit | Membatasi jendela risiko |
| Penggunaan | Sekali pakai | Mencegah reuse dari history, log, atau forward email |
| rate limit | Berdasarkan IP dan email normalisasi | Mengurangi email flood dan brute force |
| Jangan pernah mengirim password | Email bukan penyimpanan rahasia | |
| Halaman reset | noindex dan Referrer-Policy: no-referrer | Mencegah indexing dan token leak via Referer |
| Setelah reset | Revoke sesi, jangan auto-login | Membersihkan sesi yang mungkin dicuri |
| MFA | Jangan reset MFA di flow ini | Pemulihan MFA butuh verifikasi terpisah |
Cara memberi tugas ke Claude Code
Pecah pekerjaan menjadi unit yang kecil agar review tidak kabur.
- Tambahkan
PasswordResetToken,Session, danAuditLogke Prisma schema. - Buat service dengan Node crypto, hash token, expiry, one-time use, dan rate limit.
- Buat API request dan confirm di Next.js App Router.
- Buat template email, halaman reset,
Referrer-Policy,X-Robots-Tag, dan cache header. - Tambahkan test untuk non-enumeration, token expired, token reused, dan session revoke.
Prompt yang lebih aman untuk Claude Code:
Implementasikan password reset di aplikasi Next.js App Router + TypeScript + Prisma.
Constraint:
- Email yang ada dan tidak ada harus mengembalikan JSON yang sama dan waktu respons mirip
- Generate reset token dengan Node crypto minimal 32 bytes dan simpan hanya SHA-256 hash
- Token expired dalam 30 menit dan hanya bisa dipakai sekali
- Request API memakai rate limit per IP dan normalized email
- Reset page memakai noindex dan Referrer-Policy no-referrer
- Setelah sukses, revoke semua session lama dan jangan auto-login
- Jangan implementasikan MFA reset; MFA recovery adalah flow terpisah
- Jangan log raw token, reset URL, atau password
Ubah hanya file auth, Prisma, email, dan test yang diperlukan.
Prisma schema
Schema ini minimal. Jika aplikasi sudah punya User atau Session, gabungkan field yang diperlukan.
// 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])
}
Service dengan crypto, email, dan audit
Kode berikut bisa dipakai sebagai starter di aplikasi kecil. Install @prisma/client, bcryptjs, dan zod. Untuk production, ganti Map rate limit dengan Redis atau store bersama.
// 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 HTTPS");
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 routes dan header
Request API selalu mengembalikan body yang sama. Confirm API cukup memberi tahu bahwa link invalid atau expired, tanpa membocorkan informasi akun.
// 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;
Contoh kasus nyata
Kasus pertama adalah pengguna SaaS biasa yang lupa password. Expiry pendek, token sekali pakai, dan session revoke mengurangi risiko dari ponsel lama, browser bersama, atau session yang sudah dicuri.
Kasus kedua adalah akun admin. Setelah password reset, admin tetap harus login normal dan melewati MFA. Jika authenticator hilang, gunakan flow recovery terpisah seperti di panduan two-factor authentication.
Kasus ketiga adalah support. Saat pengguna mengatakan email tidak masuk, tim support perlu melihat waktu request, IP, User-Agent, dan status pengiriman email, tetapi tidak boleh melihat raw token.
Kasus keempat adalah tenant B2B yang terkena reset email flood. Rate limit per IP dan email memberi sinyal sebelum antrean support penuh.
Kesalahan umum dan review
Kesalahan paling berbahaya adalah mengembalikan pesan “email tidak terdaftar”. Itu membuat endpoint berubah menjadi alat enumerasi akun. Perbedaan waktu respons juga bisa membocorkan informasi yang sama.
Kesalahan kedua adalah menyimpan token mentah di database, log, monitoring, atau tool support. Hak baca ke sistem itu bisa berubah menjadi hak mengambil alih akun.
Kesalahan ketiga adalah token tanpa expiry atau bisa dipakai ulang. Email bisa diteruskan, browser history bisa tersinkron, dan screenshot bisa tersebar. usedAt harus diisi dalam transaction agar hanya request pertama yang berhasil.
Saat mereview output Claude Code, cek minimal: respons sama untuk email ada dan tidak ada, tidak ada raw token atau password di log, DB hanya menyimpan tokenHash, expiresAt dan usedAt diterapkan, session lama direvoke, tidak ada auto-login, reset page punya header yang benar, MFA tidak berubah, dan audit log mencatat requested serta completed.
npm install @prisma/client bcryptjs zod
npx prisma migrate dev --name add_password_reset
npm run lint
npm test
Penutup
Password reset adalah sistem autentikasi kecil. Jika memakai Claude Code, tulis keamanan sebagai acceptance criteria: tidak ada enumeration, token acak, hash storage, expiry pendek, one-time use, rate limit, email aman, noindex, Referrer-Policy, session revoke, audit log, dan pemisahan MFA.
ClaudeCodeLab dapat membantu review, training, atau implementasi auth workflow dengan Claude Code. Dengan login flow saat ini, model session, provider email, dan kebijakan MFA, kita bisa cepat menentukan bagian mana yang aman digenerate dan bagian mana yang harus direview manusia.
Saat mencoba isi artikel ini, cek empat hal dulu: email asli dan email palsu menghasilkan respons yang sama, database tidak menyimpan token mentah, token expired atau sudah dipakai gagal, dan session lama tidak bisa lagi mengakses API terlindungi. Masa biasanya memulai dari empat pemeriksaan manual ini sebelum mengubahnya menjadi automated tests.
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.