Use Cases (Diperbarui: 1/6/2026)

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 Aman dengan Claude Code: Next.js, Prisma, dan Token

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]
AreaKeputusanAlasan
Keberadaan penggunaEmail ada dan tidak ada mendapat respons yang samaMencegah account enumeration
Tokencrypto.randomBytes(32).toString("base64url")Sulit ditebak
PenyimpananSimpan SHA-256 hash sajaDB leak tidak membuka link reset
Masa berlaku30 menitMembatasi jendela risiko
PenggunaanSekali pakaiMencegah reuse dari history, log, atau forward email
rate limitBerdasarkan IP dan email normalisasiMengurangi email flood dan brute force
EmailJangan pernah mengirim passwordEmail bukan penyimpanan rahasia
Halaman resetnoindex dan Referrer-Policy: no-referrerMencegah indexing dan token leak via Referer
Setelah resetRevoke sesi, jangan auto-loginMembersihkan sesi yang mungkin dicuri
MFAJangan reset MFA di flow iniPemulihan MFA butuh verifikasi terpisah

Cara memberi tugas ke Claude Code

Pecah pekerjaan menjadi unit yang kecil agar review tidak kabur.

  1. Tambahkan PasswordResetToken, Session, dan AuditLog ke Prisma schema.
  2. Buat service dengan Node crypto, hash token, expiry, one-time use, dan rate limit.
  3. Buat API request dan confirm di Next.js App Router.
  4. Buat template email, halaman reset, Referrer-Policy, X-Robots-Tag, dan cache header.
  5. 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.

#Claude Code #password reset #authentication #security #email
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.