Reset Password API yang aman di Laravel tidak cukup hanya mengirim email dan menyimpan token. Implementasi yang baik harus mencegah user enumeration, membatasi abuse dengan rate limit, menyimpan token secara aman, memberi masa berlaku yang jelas, dan meninggalkan jejak audit yang bisa ditinjau saat insiden.
Artikel ini fokus pada dua endpoint inti: request reset dan confirm reset. Kita akan bahas desain alur, struktur tabel, contoh implementasi Laravel, trade-off token database vs signed URL, kapan sesi lama perlu dicabut, serta kesalahan umum yang sering membuat fitur reset password menjadi titik lemah aplikasi.
Tujuan dan model ancaman
Fitur reset password biasanya diserang melalui beberapa pola yang sama:
- User enumeration: penyerang mencoba mengetahui apakah email tertentu terdaftar.
- Token theft: token bocor dari log, database, browser history, atau email forwarding.
- Brute force / abuse: endpoint reset dibanjiri request untuk spam email atau menebak token.
- Replay: token lama dipakai ulang setelah password sudah diganti.
- Post-reset session hijack: sesi lama tetap aktif walau password sudah diubah.
Karena itu, target implementasi kita adalah:
- Respons API tetap generik agar tidak membocorkan keberadaan user.
- Token random kuat, disimpan dalam bentuk hash, dan memiliki TTL.
- Token hanya bisa dipakai sekali dan diinvalidasi setelah sukses.
- Rate limit diterapkan pada request reset dan submit reset.
- Perubahan password tercatat dalam audit log.
- Email notifikasi tidak membocorkan data sensitif.
Desain alur endpoint reset password
1. Request reset
Endpoint misalnya POST /api/password/forgot.
Input minimal:
emailatau identifier login yang relevan
Alur aman:
- Validasi format input.
- Selalu kembalikan respons generik, baik email ditemukan maupun tidak.
- Jika user ada, buat token acak kriptografis kuat.
- Simpan hash token, bukan token plaintext.
- Set expired_at atau TTL yang jelas.
- Hapus atau invalidasi token reset lama milik user tersebut.
- Kirim email berisi link reset atau kode sekali pakai.
- Tulis audit log untuk request reset.
2. Confirm reset
Endpoint misalnya POST /api/password/reset.
Input minimal:
emailtokenpasswordpassword_confirmation
Alur aman:
- Validasi semua input.
- Cari user secara aman tanpa memberi sinyal apakah email valid.
- Ambil record token reset aktif untuk user.
- Bandingkan hash token dengan input token menggunakan mekanisme aman.
- Pastikan token belum kedaluwarsa dan belum dipakai.
- Update password dengan hash password modern.
- Invalidasi semua token reset aktif user.
- Pertimbangkan revoke sesi lama dan token API lama.
- Kirim notifikasi bahwa password telah diubah.
- Tulis audit log hasil reset, termasuk sukses atau gagal.
Struktur tabel yang disarankan
Laravel memiliki mekanisme bawaan untuk reset password, tetapi untuk API yang lebih ketat dan dapat diaudit, sering kali lebih baik memakai tabel yang eksplisit. Contoh struktur:
password_reset_tokens
- id (bigint / uuid)
- user_id (nullable jika ingin longgar, tetapi umumnya lebih baik relasional)
- email
- token_hash
- requested_ip
- requested_user_agent
- expires_at
- used_at
- created_at
- updated_at
security_audit_logs
- id
- user_id (nullable)
- event_type
- identifier
- ip_address
- user_agent
- metadata (json)
- created_atCatatan desain:
- token_hash menyimpan hasil hash token, bukan token asli.
- used_at memudahkan token sekali pakai.
- requested_ip dan requested_user_agent berguna untuk audit dan investigasi.
- identifier di audit log bisa berisi email yang sudah dinormalisasi, atau bentuk yang disamarkan bila diperlukan kebijakan privasi tertentu.
Jika Anda ingin satu user hanya punya satu token aktif, tambahkan kebijakan aplikasi yang menghapus token lama sebelum membuat yang baru. Ini biasanya lebih sederhana dibanding mengizinkan banyak token aktif sekaligus.
Menyimpan token dengan aman: hash, TTL, dan invalidasi
Kenapa token harus di-hash
Jangan simpan token reset dalam bentuk plaintext. Jika database bocor, token plaintext bisa langsung dipakai untuk takeover akun. Dengan hash, kebocoran database tidak otomatis membuat token bisa dipakai.
Pola yang umum:
- Buat token random kuat, misalnya dari generator kriptografis.
- Kirim token asli hanya sekali ke user, biasanya lewat email.
- Simpan hanya nilai hash dari token di database.
- Saat verifikasi, hash input token lalu bandingkan dengan nilai tersimpan.
Contoh pembuatan token dan penyimpanannya:
$plainToken = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $plainToken);
PasswordResetToken::create([
'user_id' => $user->id,
'email' => $user->email,
'token_hash' => $tokenHash,
'requested_ip' => $request->ip(),
'requested_user_agent' => substr((string) $request->userAgent(), 0, 500),
'expires_at' => now()->addMinutes(30),
]);Kenapa SHA-256 cukup untuk token? Karena token acak panjang tidak berasal dari password yang mudah ditebak manusia. Yang penting adalah token benar-benar random, cukup panjang, dan tidak disimpan plaintext. Untuk password user, tetap gunakan hash password yang memang dirancang untuk password seperti bcrypt atau Argon2 melalui Hash::make().
TTL token
TTL harus cukup lama agar email sempat diterima, tetapi tidak terlalu lama sehingga memperbesar jendela serangan. Nilai praktis yang sering dipakai adalah puluhan menit, bukan berhari-hari. Pilih TTL berdasarkan risiko aplikasi dan pengalaman pengguna.
Gunakan pemeriksaan eksplisit:
if ($resetToken->used_at !== null || $resetToken->expires_at->isPast()) {
// token tidak valid
}Invalidasi token lama
Kesalahan umum adalah membiarkan banyak token tetap aktif. Praktik yang lebih aman:
- Saat membuat token baru, invalidasi semua token aktif user.
- Saat reset berhasil, tandai token sebagai
used_atdan hapus atau nonaktifkan token aktif lain.
Contoh:
PasswordResetToken::where('user_id', $user->id)
->whereNull('used_at')
->delete();Atau jika Anda butuh jejak audit penuh, jangan hapus; cukup tandai sebagai tidak berlaku melalui kolom status atau used_at/revoked_at.
Implementasi endpoint di Laravel
Validasi request reset
Untuk endpoint POST /api/password/forgot, validasi format tanpa memberi tahu apakah user ada:
use Illuminate\Foundation\Http\FormRequest;
class ForgotPasswordRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => ['required', 'string', 'email:rfc'],
];
}
}Controller:
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
class ForgotPasswordController
{
public function __invoke(ForgotPasswordRequest $request): JsonResponse
{
$email = mb_strtolower(trim($request->input('email')));
$user = User::query()->where('email', $email)->first();
if ($user) {
DB::transaction(function () use ($user, $request) {
PasswordResetToken::where('user_id', $user->id)
->whereNull('used_at')
->delete();
$plainToken = bin2hex(random_bytes(32));
PasswordResetToken::create([
'user_id' => $user->id,
'email' => $user->email,
'token_hash' => hash('sha256', $plainToken),
'requested_ip' => $request->ip(),
'requested_user_agent' => substr((string) $request->userAgent(), 0, 500),
'expires_at' => now()->addMinutes(30),
]);
dispatch(new SendPasswordResetMailJob($user, $plainToken));
SecurityAuditLog::create([
'user_id' => $user->id,
'event_type' => 'password_reset_requested',
'identifier' => $user->email,
'ip_address' => $request->ip(),
'user_agent' => substr((string) $request->userAgent(), 0, 500),
'metadata' => ['channel' => 'email'],
]);
});
}
return response()->json([
'message' => 'Jika akun terdaftar, instruksi reset password akan dikirim ke email tersebut.'
]);
}
}Hal penting pada contoh di atas:
- Email dinormalisasi agar lookup konsisten.
- Respons tetap sama untuk user ada atau tidak.
- Pengiriman email sebaiknya melalui queue agar endpoint cepat dan lebih tahan lonjakan trafik.
- Audit log tetap dicatat untuk kejadian yang relevan. Untuk email yang tidak ditemukan, Anda bisa mencatat event tanpa
user_idagar tetap ada jejak abuse.
Validasi confirm reset
class ResetPasswordRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => ['required', 'string', 'email:rfc'],
'token' => ['required', 'string', 'min:32'],
'password' => ['required', 'string', 'confirmed', \Illuminate\Validation\Rules\Password::defaults()],
];
}
}Controller reset:
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;
class ResetPasswordController
{
public function __invoke(ResetPasswordRequest $request): JsonResponse
{
$email = mb_strtolower(trim($request->input('email')));
$plainToken = $request->input('token');
$user = User::query()->where('email', $email)->first();
if (! $user) {
return $this->invalidResponse();
}
$resetToken = PasswordResetToken::query()
->where('user_id', $user->id)
->whereNull('used_at')
->latest('id')
->first();
if (! $resetToken) {
return $this->invalidResponse($request, $user);
}
$incomingHash = hash('sha256', $plainToken);
$valid = hash_equals($resetToken->token_hash, $incomingHash)
&& $resetToken->expires_at->isFuture();
if (! $valid) {
return $this->invalidResponse($request, $user);
}
DB::transaction(function () use ($user, $request, $resetToken) {
$user->forceFill([
'password' => Hash::make($request->input('password')),
])->save();
$resetToken->forceFill([
'used_at' => now(),
])->save();
PasswordResetToken::where('user_id', $user->id)
->whereNull('used_at')
->update(['used_at' => now()]);
SecurityAuditLog::create([
'user_id' => $user->id,
'event_type' => 'password_reset_completed',
'identifier' => $user->email,
'ip_address' => $request->ip(),
'user_agent' => substr((string) $request->userAgent(), 0, 500),
'metadata' => [],
]);
});
// Opsional: revoke sesi / token API lama di sini.
// Opsional: kirim notifikasi password changed.
return response()->json([
'message' => 'Password berhasil diperbarui.'
]);
}
private function invalidResponse($request = null, $user = null): JsonResponse
{
if ($request) {
SecurityAuditLog::create([
'user_id' => $user?->id,
'event_type' => 'password_reset_failed',
'identifier' => $request->input('email'),
'ip_address' => $request->ip(),
'user_agent' => substr((string) $request->userAgent(), 0, 500),
'metadata' => ['reason' => 'invalid_or_expired_token'],
]);
}
return response()->json([
'message' => 'Token reset tidak valid atau sudah kedaluwarsa.'
], 422);
}
}Pada endpoint konfirmasi reset, pesan error boleh menjelaskan bahwa token tidak valid atau kedaluwarsa, karena pada tahap ini user memang sudah memegang token. Namun tetap hindari pesan yang terlalu spesifik seperti:
- Email tidak terdaftar
- Token benar tetapi email salah
- Akun nonaktif
Semakin spesifik pesannya, semakin banyak informasi yang bocor.
Rate limiting per IP dan identifier
Salah satu kesalahan umum adalah menerapkan limit hanya pada route login. Endpoint reset password juga harus dilindungi, bahkan sering kali lebih ketat karena bisa dipakai untuk spam email atau reconnaissance.
Ada dua dimensi limit yang berguna:
- Per IP: menahan abuse dari satu sumber jaringan.
- Per identifier seperti email: mencegah satu akun ditarget terus-menerus dari banyak IP.
Di Laravel, Anda bisa mendefinisikan limiter sendiri:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('password-forgot', function (Request $request) {
$email = mb_strtolower(trim((string) $request->input('email')));
return [
Limit::perMinute(5)->by('ip:' . $request->ip()),
Limit::perHour(3)->by('email:' . sha1($email)),
];
});
RateLimiter::for('password-reset', function (Request $request) {
$email = mb_strtolower(trim((string) $request->input('email')));
return [
Limit::perMinute(10)->by('ip:' . $request->ip()),
Limit::perHour(10)->by('email:' . sha1($email)),
];
});Lalu pasang middleware:
Route::post('/api/password/forgot', ForgotPasswordController::class)
->middleware('throttle:password-forgot');
Route::post('/api/password/reset', ResetPasswordController::class)
->middleware('throttle:password-reset');Beberapa catatan:
- Nilai limit harus disesuaikan dengan pola trafik aplikasi Anda.
- Meng-hash email pada key limiter membantu mengurangi penyimpanan identifier mentah di cache key.
- Jangan lupa meninjau perilaku di balik proxy atau load balancer agar IP klien terbaca benar.
Jika aplikasi Anda sering menjadi target abuse, pertimbangkan lapisan tambahan seperti CAPTCHA adaptif atau challenge sekunder setelah ambang tertentu terlampaui. Namun jangan menjadikannya lapisan utama; fondasi seperti rate limit dan respons generik tetap wajib.
Respons generik untuk mencegah user enumeration
User enumeration biasanya terjadi pada endpoint request reset. Respons seperti email tidak ditemukan membuat penyerang mudah memverifikasi daftar email aktif.
Gunakan satu pesan tetap, misalnya:
{
"message": "Jika akun terdaftar, instruksi reset password akan dikirim ke email tersebut."
}Selain body respons, perhatikan juga:
- Status code sebaiknya konsisten untuk request reset.
- Waktu respons jangan terlalu berbeda antara email valid dan tidak valid bila memungkinkan.
- Log aplikasi jangan menulis token, email lengkap, atau alasan sensitif ke log yang mudah diakses.
Notifikasi email yang aman
Email reset password harus berisi informasi secukupnya, tidak lebih.
- Jangan sertakan password lama atau data sensitif lain.
- Jangan menampilkan token plaintext di log job atau exception.
- Gunakan link HTTPS.
- Sertakan masa berlaku token secara jelas.
- Sediakan pesan abaikan email ini bila user tidak meminta reset.
Contoh URL yang dikirim:
https://app.example.com/reset-password?token=...&email=...Trade-off penting: token di query string berpotensi muncul di browser history, referrer, atau log tertentu. Untuk mengurangi risiko:
- Pastikan frontend tidak memuat resource pihak ketiga pada halaman reset sebelum token diproses.
- Segera tukarkan token menjadi state yang lebih aman di frontend bila perlu.
- Gunakan kebijakan Referrer-Policy yang sesuai.
Setelah password berhasil diganti, kirim email notifikasi terpisah seperti Password Anda baru saja diubah. Email ini tidak berisi link login otomatis atau detail sensitif, tetapi sangat berguna untuk deteksi kompromi.
Audit log: apa yang perlu dicatat
Audit log membantu tim operasi menelusuri insiden, mendeteksi abuse, dan membuktikan alur kejadian. Minimal catat event berikut:
password_reset_requestedpassword_reset_completedpassword_reset_failedpassword_changed_authenticatedjika user mengganti password dari sesi login normal
Data yang berguna:
user_idbila diketahui- identifier yang relevan
- IP address
- user agent
- waktu kejadian
- metadata seperti channel email, alasan gagal generik, atau request id
Hindari menyimpan:
- Token plaintext
- Password dalam bentuk apa pun
- Payload mentah yang berisi data sensitif jika tidak benar-benar perlu
Audit log bukan pengganti application log. Audit log harus lebih terstruktur, tahan perubahan format, dan mudah di-query untuk investigasi.
Token database vs signed URL
Token database
Pendekatan ini paling umum untuk reset password API.
Kelebihan:
- Mudah diinvalidasi kapan saja.
- Bisa dibuat single-use dengan jelas.
- Mudah diaudit.
- Bisa dikaitkan ke user, IP, user agent, dan status pemakaian.
Kekurangan:
- Butuh penyimpanan dan cleanup token kedaluwarsa.
- Verifikasi selalu menyentuh database.
Signed URL
Signed URL cocok untuk link bertanda tangan dengan masa berlaku, tetapi untuk reset password ada trade-off penting.
Kelebihan:
- Validasi tanda tangan bisa sederhana.
- Tidak selalu butuh record token khusus jika desainnya murni stateless.
Kekurangan:
- Lebih sulit membuat single-use yang kuat tanpa state tambahan.
- Invalidasi sebelum expiry tidak semudah token database.
- Audit dan kontrol multi-token biasanya lebih terbatas jika sepenuhnya stateless.
Kapan pilih mana?
- Pilih token database bila Anda butuh invalidasi eksplisit, audit yang kuat, dan kontrol keamanan yang lebih granular.
- Pilih signed URL hanya bila Anda benar-benar memahami konsekuensi stateless dan masih menambahkan mekanisme state bila butuh single-use atau revocation.
Untuk reset password akun pengguna, token database umumnya lebih aman dan lebih mudah dioperasikan.
Kapan perlu revoke sesi lama setelah password diganti
Setelah password di-reset, pertanyaan pentingnya adalah apakah sesi lama dan token API lama harus dicabut. Jawaban praktisnya: sering kali ya, terutama untuk skenario reset lewat email karena ada indikasi akun mungkin sedang dalam keadaan terancam.
Pertimbangkan revoke sesi/token lama jika:
- Reset dilakukan melalui email karena user lupa password.
- Aplikasi menyimpan sesi panjang atau remember me.
- Ada token API personal atau session device yang aktif di banyak perangkat.
- Aplikasi menangani data sensitif.
Trade-off-nya adalah pengalaman pengguna: semua device akan logout dan perlu login ulang. Namun untuk banyak aplikasi, ini adalah keputusan keamanan yang tepat.
Strategi yang umum:
- Reset via email: revoke semua sesi dan token API lama.
- Change password dari sesi terautentikasi: minimal tawarkan opsi logout dari perangkat lain, atau lakukan revoke semua jika risikonya tinggi.
Implementasi spesifik revoke sesi bergantung pada driver session dan sistem token yang Anda pakai. Pastikan desain autentikasi aplikasi Anda mendukung invalidasi lintas perangkat bila itu kebutuhan keamanan.
Middleware, rule, dan praktik Laravel yang relevan
- Form Request untuk validasi input yang konsisten.
- throttle middleware atau limiter khusus untuk forgot/reset password.
- Queue untuk pengiriman email agar request tidak lambat.
- Database transaction untuk update password + invalidasi token + audit log secara atomik.
- Hash facade untuk password user.
- Event/Notification bila ingin memisahkan logika notifikasi dari controller.
Jika endpoint berada di API publik, pastikan juga:
- CORS disetel sesuai kebutuhan nyata, bukan terlalu longgar.
- Trusted proxies dikonfigurasi dengan benar agar IP asli tidak salah.
- Exception handler tidak membocorkan stack trace di production.
Kesalahan umum yang perlu dihindari
- Menyimpan token plaintext di database.
- Mengirim pesan error terlalu spesifik pada request reset.
- Rate limit hanya di route login, bukan di forgot/reset password.
- Tidak menghapus atau menginvalidasi token lama.
- Tidak memberi TTL atau TTL terlalu lama.
- Tidak mencatat audit log untuk event keamanan penting.
- Menaruh token di log, termasuk log debug, exception, atau job payload.
- Tidak mengirim notifikasi perubahan password setelah reset sukses.
- Tidak menormalisasi email sebelum lookup dan limit keying.
- Tidak mempertimbangkan revoke sesi lama setelah reset berhasil.
Debugging dan pengujian yang sebaiknya dilakukan
Skenario uji minimal
- Request reset dengan email valid dan tidak valid harus memberi respons yang sama.
- Token valid bisa dipakai satu kali.
- Token kedaluwarsa ditolak.
- Token lama tidak berlaku setelah token baru dibuat.
- Rate limit aktif per IP dan per email.
- Password baru tersimpan sebagai hash, bukan plaintext.
- Audit log tercatat untuk request, gagal, dan sukses.
- Notifikasi email terkirim tanpa membocorkan token ke log.
Hal yang sering membingungkan saat debugging
- IP semua request sama: cek konfigurasi proxy/load balancer.
- Email tidak terkirim: pastikan queue worker berjalan jika notifikasi diantrikan.
- Token selalu gagal: cek normalisasi input, proses hashing, dan encoding token di URL/frontend.
- Rate limit terasa tidak konsisten: cek backend cache yang dipakai limiter, terutama pada multi-instance deployment.
Checklist hardening implementasi
- Gunakan token acak kriptografis kuat.
- Simpan hanya hash token di database.
- Terapkan TTL yang jelas dan cukup singkat.
- Buat token single-use dan invalidasi token lama.
- Pakai respons generik pada endpoint request reset.
- Terapkan rate limit per IP dan per identifier.
- Validasi input dengan Form Request dan rule password yang kuat.
- Kirim email reset melalui queue.
- Jangan log token plaintext atau data sensitif.
- Catat audit log untuk request, gagal, dan sukses.
- Kirim notifikasi setelah password berhasil diubah.
- Pertimbangkan revoke sesi dan token API lama setelah reset.
- Gunakan HTTPS end-to-end.
- Uji flow expiry, replay, concurrency, dan abuse.
Penutup
Membangun Reset Password API yang Aman di Laravel berarti memperlakukan fitur ini sebagai alur keamanan penuh, bukan sekadar formulir kirim email. Fokus utamanya adalah token yang di-hash, TTL yang masuk akal, invalidasi yang tegas, rate limit di titik yang tepat, respons generik anti-enumeration, dan audit log yang bisa diandalkan.
Jika Anda harus memilih satu pendekatan yang paling aman dan operasional untuk kebanyakan aplikasi Laravel, gunakan token berbasis database yang di-hash, queued email notification, limiter per IP dan identifier, lalu revoke sesi lama setelah reset sukses untuk skenario berisiko. Pendekatan ini tidak paling ringkas, tetapi paling mudah diaudit, di-debug, dan dipertahankan dalam jangka panjang.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!