Reset password aman di Laravel tidak cukup hanya mengirim email berisi link. Implementasi yang layak produksi harus memastikan token tidak disimpan mentah, punya masa berlaku yang jelas, tidak bisa dipakai ulang, tidak membuka celah user enumeration, dan tahan terhadap brute force maupun kebocoran token di log.
Di artikel ini, kita fokus pada alur forgot-password end-to-end yang praktis: mulai dari request reset, penyimpanan token dalam bentuk hash, validasi dan konsumsi token, rate limit per IP dan identifier, audit log minimum, sampai pengamanan notifikasi email dan skenario pengujian fitur.
Tujuan desain dan ancaman yang harus ditangani
Sebelum menulis kode, penting menentukan ancaman utamanya. Untuk fitur reset password, beberapa risiko yang paling umum adalah:
- User enumeration: endpoint lupa password membocorkan apakah email terdaftar atau tidak.
- Token bocor di log: token muncul di access log, exception log, analytics, atau referer.
- Brute force token: penyerang mencoba banyak token ke endpoint reset.
- Replay: link reset dipakai ulang setelah password berhasil diubah.
- Flood email: satu email atau satu IP men-trigger notifikasi reset berulang kali.
- Email channel abuse: pengiriman sinkron membuat endpoint lambat atau bisa disalahgunakan untuk denial of service ringan.
Karena itu, target implementasinya adalah:
- Token dibuat secara kriptografis acak.
- Token hanya disimpan dalam bentuk hash di database.
- Token memiliki TTL yang singkat dan jelas.
- Token diinvalidasi setelah dipakai.
- Response endpoint request reset harus seragam untuk mencegah enumeration.
- Diterapkan rate limit per IP dan per identifier.
- Pengiriman email dilakukan lewat queue.
- Ada audit log minimum tanpa menyimpan data sensitif.
Desain alur reset password yang aman
1. Request forgot-password
Client mengirim email ke endpoint, misalnya POST /forgot-password. Server melakukan validasi input, rate limit, lalu:
- Selalu mengembalikan respons generik seperti: "Jika akun terdaftar, kami telah mengirim link reset password."
- Mencari user berdasarkan email secara normal.
- Jika user ada, buat token acak, simpan hash token ke tabel reset, atur waktu kedaluwarsa, lalu antrekan email notifikasi.
- Jika user tidak ada, jangan kirim email, tetapi tetap kembalikan respons yang sama.
2. User membuka link reset
Link reset umumnya membawa identifier akun dan token. Contoh paling umum adalah email dan token pada query string. Ini praktis, tetapi ada trade-off: token pada URL berisiko tercatat di log atau terkirim sebagai referer ke pihak ketiga jika halaman reset memuat aset eksternal. Karena itu, halaman reset sebaiknya minim aset pihak ketiga, dan token jangan pernah ditulis ke log aplikasi.
3. Submit password baru
Client mengirim email, token, password baru, dan konfirmasi password ke endpoint, misalnya POST /reset-password. Server harus:
- Validasi format input dan kekuatan password.
- Mencari record reset berdasarkan identifier.
- Memeriksa expired.
- Membandingkan token mentah dari request dengan hash di database.
- Jika valid, ubah password user dengan hash password yang kuat.
- Hapus atau invalidasi record reset agar token tidak bisa dipakai ulang.
- Opsional tetapi disarankan: rotasi session, hapus remember token lama, dan log kejadian reset sukses.
Skema data dan penyimpanan token hash
Prinsip utamanya: jangan simpan token reset dalam bentuk plaintext. Jika database bocor, token mentah yang masih aktif bisa langsung dipakai penyerang. Dengan menyimpan hash, kebocoran database tidak otomatis memberi akses reset.
Struktur tabel minimal dapat terlihat seperti ini:
Schema::create('password_reset_tokens', function ($table) {
$table->id();
$table->string('email')->index();
$table->string('token_hash');
$table->timestamp('expires_at')->index();
$table->timestamp('used_at')->nullable();
$table->string('requested_ip', 45)->nullable();
$table->string('user_agent', 512)->nullable();
$table->timestamps();
});Beberapa catatan:
- email dipakai sebagai identifier praktis. Jika aplikasi Anda login dengan username atau nomor telepon, sesuaikan identifier-nya.
- token_hash menyimpan hasil hash token, bukan token mentah.
- expires_at memudahkan validasi TTL dan pembersihan data.
- used_at berguna untuk audit dan mencegah replay jika Anda memilih soft invalidate. Jika ingin lebih sederhana, record bisa langsung dihapus saat token dipakai.
- requested_ip dan user_agent membantu audit dasar, tetapi jangan diperlakukan sebagai kontrol keamanan utama.
Membuat token dan hash
Gunakan generator acak yang aman secara kriptografis. Di PHP/Laravel, pendekatan aman adalah menggunakan byte acak dan mengubahnya menjadi string yang cocok untuk URL.
namespace App\Services;
use App\Models\PasswordResetToken;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class PasswordResetService
{
public function createTokenFor(User $user, ?string $ip = null, ?string $userAgent = null): string
{
PasswordResetToken::query()
->where('email', $user->email)
->delete();
$plainToken = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
PasswordResetToken::create([
'email' => $user->email,
'token_hash' => Hash::make($plainToken),
'expires_at' => Carbon::now()->addMinutes(30),
'requested_ip' => $ip,
'user_agent' => $userAgent,
]);
return $plainToken;
}
}Kenapa memakai Hash::make() dan bukan hash cepat seperti SHA-256? Karena hash lambat lebih aman jika database bocor. Trade-off-nya adalah verifikasi token sedikit lebih mahal. Untuk volume normal fitur reset password, biaya ini umumnya layak. Jika Anda butuh desain yang sangat hemat CPU, hash cepat dengan secret pepper bisa dipertimbangkan, tetapi implementasinya lebih sensitif dan mudah salah konfigurasi.
TTL dan pembersihan token
TTL umum untuk reset password biasanya singkat, misalnya 15-60 menit. Jangan terlalu lama karena memperbesar jendela serangan. Jangan juga terlalu singkat jika pengguna Anda sering terlambat membuka email.
Token kedaluwarsa sebaiknya dibersihkan secara berkala dengan scheduler:
use Illuminate\Support\Facades\Schedule;
use App\Models\PasswordResetToken;
Schedule::call(function () {
PasswordResetToken::query()
->where('expires_at', '<', now())
->orWhereNotNull('used_at')
->delete();
})->hourly();Controller dan service: implementasi yang mudah diaudit
Pisahkan logika reset password ke service agar controller tetap tipis dan alurnya mudah diuji. Dua endpoint yang biasanya dibutuhkan adalah:
POST /forgot-passwordPOST /reset-password
Request reset password
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Jobs\SendPasswordResetEmail;
use App\Models\User;
use App\Services\PasswordResetService;
use Illuminate\Http\Request;
class ForgotPasswordController extends Controller
{
public function __invoke(Request $request, PasswordResetService $service)
{
$data = $request->validate([
'email' => ['required', 'string', 'email:rfc', 'max:255'],
]);
$email = mb_strtolower(trim($data['email']));
$user = User::query()->where('email', $email)->first();
if ($user) {
$token = $service->createTokenFor(
$user,
$request->ip(),
substr((string) $request->userAgent(), 0, 512)
);
SendPasswordResetEmail::dispatch($user->email, $token);
}
return response()->json([
'message' => 'Jika akun terdaftar, kami telah mengirim link reset password.'
]);
}
}Poin penting di sini:
- Respons selalu sama, baik email ada maupun tidak.
- Email dinormalisasi, misalnya trim dan lowercase, agar pencarian konsisten.
- Pengiriman email dilakukan lewat job queue, bukan sinkron di request thread.
Reset password dengan konsumsi token
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\PasswordResetToken;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class ResetPasswordController extends Controller
{
public function __invoke(Request $request)
{
$data = $request->validate([
'email' => ['required', 'string', 'email:rfc', 'max:255'],
'token' => ['required', 'string', 'max:512'],
'password' => ['required', 'string', 'confirmed', 'min:12'],
]);
$email = mb_strtolower(trim($data['email']));
$reset = PasswordResetToken::query()
->where('email', $email)
->latest('id')
->first();
if (! $reset || $reset->used_at || $reset->expires_at->isPast()) {
return response()->json(['message' => 'Token reset tidak valid atau sudah kedaluwarsa.'], 422);
}
if (! Hash::check($data['token'], $reset->token_hash)) {
return response()->json(['message' => 'Token reset tidak valid atau sudah kedaluwarsa.'], 422);
}
DB::transaction(function () use ($email, $data, $reset) {
$user = User::query()->where('email', $email)->lockForUpdate()->firstOrFail();
$user->forceFill([
'password' => Hash::make($data['password']),
'remember_token' => Str::random(60),
])->save();
$reset->forceFill([
'used_at' => now(),
])->save();
PasswordResetToken::query()
->where('email', $email)
->where('id', '!=', $reset->id)
->delete();
});
return response()->json([
'message' => 'Password berhasil direset.'
]);
}
}Ada beberapa alasan teknis di balik desain ini:
- Validasi generik untuk token invalid/expired mencegah bocor informasi yang tidak perlu.
- Transaksi database memastikan perubahan password dan invalidasi token terjadi atomik.
- Rotasi remember token membantu memutus sesi “remember me” lama.
- used_at mencegah token dipakai ulang. Jika ingin lebih ketat, hapus record tersebut langsung di dalam transaksi.
Jika aplikasi Anda memiliki banyak sesi aktif di berbagai device, pertimbangkan tambahan proses untuk mengakhiri sesi lain setelah reset password berhasil. Implementasinya tergantung cara aplikasi menyimpan session.
Rate limit per IP dan identifier
Rate limit wajib diterapkan minimal di dua titik: endpoint request reset dan endpoint submit password baru. Tujuannya berbeda:
- Pada
/forgot-password, limit mencegah flood email dan enumeration skala besar. - Pada
/reset-password, limit mencegah brute force token.
Menggunakan RateLimiter Laravel
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
RateLimiter::for('forgot-password', function (Request $request) {
$email = mb_strtolower((string) $request->input('email'));
return [
Limit::perMinute(5)->by('fp:ip:' . $request->ip()),
Limit::perHour(3)->by('fp:email:' . sha1($email)),
];
});
RateLimiter::for('reset-password', function (Request $request) {
$email = mb_strtolower((string) $request->input('email'));
return [
Limit::perMinute(10)->by('rp:ip:' . $request->ip()),
Limit::perMinute(5)->by('rp:email:' . sha1($email)),
];
});
}
}Kemudian terapkan middleware throttle pada route:
use App\Http\Controllers\Auth\ForgotPasswordController;
use App\Http\Controllers\Auth\ResetPasswordController;
use Illuminate\Support\Facades\Route;
Route::post('/forgot-password', ForgotPasswordController::class)
->middleware('throttle:forgot-password');
Route::post('/reset-password', ResetPasswordController::class)
->middleware('throttle:reset-password');Kenapa perlu kombinasi per IP dan per identifier?
- Limit per IP menahan satu sumber menyerang banyak akun.
- Limit per identifier menahan banyak IP menyerang satu akun tertentu atau membanjiri satu inbox.
Jika aplikasi Anda memakai Redis untuk cache/rate limiting, itu biasanya lebih cocok untuk beban tinggi daripada driver berbasis file atau database.
Trade-off rate limit
Limit yang terlalu ketat bisa mengganggu pengguna sah, terutama di jaringan kantor atau mobile carrier yang berbagi IP. Karena itu, angka limit harus diuji terhadap pola trafik nyata. Jika ragu, mulai konservatif dan tambahkan observabilitas lebih dulu.
Pengamanan email notifikasi reset
Email reset password adalah bagian kritis karena token dikirim lewat kanal ini. Beberapa praktik yang layak diterapkan:
Kirim lewat queue
Jangan kirim email secara sinkron dari controller. Gunakan queue agar endpoint tetap cepat dan tidak mudah diblokir oleh latensi SMTP atau provider email.
namespace App\Jobs;
use App\Mail\PasswordResetMail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class SendPasswordResetEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public string $email,
public string $token,
) {}
public function handle(): void
{
Mail::to($this->email)->send(new PasswordResetMail($this->token, $this->email));
}
}Untuk environment produksi, gunakan worker queue yang stabil dan konfigurasi retry yang masuk akal. Hindari retry tak terbatas untuk email reset karena bisa menghasilkan pengiriman ganda yang membingungkan pengguna.
Minimalkan kebocoran token
- Jangan log URL reset lengkap.
- Jangan masukkan token ke log aplikasi, exception context, atau audit trail.
- Hindari memuat script, image, atau aset pihak ketiga di halaman reset agar token di URL tidak bocor melalui header referer.
- Pertimbangkan agar halaman reset cepat menyalin token dari query string ke form lalu membersihkan URL di browser dengan
history.replaceState. Ini membantu mengurangi jejak token di browser history, walau tidak menghapus risiko pada log server yang sudah terlanjur mencatat request awal.
Isi email yang aman
Email sebaiknya berisi:
- Penjelasan singkat bahwa ada permintaan reset password.
- Link reset dengan masa berlaku.
- Peringatan untuk mengabaikan email jika bukan pengguna yang meminta.
- Tanpa data sensitif tambahan.
Jangan menulis password, PIN, atau informasi akun yang tidak perlu ke email.
Anti user enumeration dan validasi input
Respons harus konsisten
Kesalahan paling umum adalah memberi pesan berbeda untuk email terdaftar dan tidak terdaftar. Bahkan perbedaan kecil pada pesan, status code, atau durasi respons bisa membantu enumerasi akun.
Praktiknya:
- Gunakan pesan respons yang sama.
- Usahakan jalur kode tetap sederhana dan tidak terlalu berbeda.
- Jangan tampilkan detail seperti “email tidak ditemukan”.
Validasi input
Validasi penting, tetapi jangan menjadikannya celah informasi. Beberapa aturan yang masuk akal:
- Email: wajib, string, format email yang wajar, batas panjang.
- Token: wajib, string, batas panjang.
- Password baru: wajib, konfirmasi cocok, panjang minimum yang kuat. Jika perlu, tambahkan rule kompleksitas sesuai kebijakan keamanan Anda.
Hindari validasi yang terlalu ketat pada token jika bisa membocorkan format internal. Cukup pastikan token adalah string dengan ukuran wajar.
Audit log minimum yang tetap aman
Fitur reset password perlu jejak audit dasar, tetapi jangan sampai audit justru menyimpan rahasia. Minimal, catat event berikut:
- Permintaan reset dibuat untuk identifier tertentu.
- Email reset berhasil dijadwalkan ke queue.
- Reset password berhasil.
- Rate limit terpicu berulang untuk endpoint reset.
Data yang aman untuk dicatat biasanya:
- Email yang sudah diparsialkan atau di-hash jika perlu.
- Waktu kejadian.
- IP sumber.
- User-Agent ringkas.
- Status hasil: requested, throttled, succeeded, failed-invalid-token.
Data yang jangan dicatat:
- Token reset mentah.
- Password baru atau hash-nya dalam log.
- URL reset lengkap jika mengandung token.
logger()->info('password_reset_requested', [
'email_sha1' => sha1($email),
'ip' => $request->ip(),
]);Untuk banyak sistem, log seperti ini sudah cukup membantu investigasi tanpa memperbesar risiko kebocoran data.
Skenario pengujian fitur yang wajib ada
Pengujian untuk reset password sebaiknya tidak berhenti pada “email terkirim”. Uji alurnya sebagai fitur keamanan.
1. Request reset tidak membocorkan keberadaan user
- Email terdaftar dan tidak terdaftar mengembalikan status code yang sama.
- Pesan respons sama.
2. Token disimpan sebagai hash
- Setelah request reset, pastikan nilai di database berbeda dari token mentah yang dikirim lewat email/job.
3. Token valid bisa mereset password
- Reset dengan token valid mengubah password user.
- Login dengan password baru berhasil.
4. Token expired ditolak
- Majukan waktu melewati TTL, lalu submit reset dan pastikan gagal.
5. Token tidak bisa dipakai ulang
- Submit reset sukses sekali, lalu ulangi request yang sama dan pastikan ditolak.
6. Rate limit aktif
- Kirim request berulang ke endpoint forgot-password sampai melewati ambang batas.
- Pastikan server mengembalikan respons throttle yang sesuai.
7. Input tidak valid ditangani dengan benar
- Email kosong, token kosong, password terlalu pendek, atau konfirmasi password tidak cocok.
Contoh ide test fitur
public function test_token_cannot_be_reused_after_successful_reset(): void
{
$user = User::factory()->create(['email' => '[email protected]']);
$service = app(\App\Services\PasswordResetService::class);
$token = $service->createTokenFor($user, '127.0.0.1', 'PHPUnit');
$payload = [
'email' => '[email protected]',
'token' => $token,
'password' => 'PasswordBaruYangKuat123',
'password_confirmation' => 'PasswordBaruYangKuat123',
];
$this->postJson('/reset-password', $payload)->assertOk();
$this->postJson('/reset-password', $payload)->assertStatus(422);
}Selain feature test, unit test untuk service pembuat token juga berguna, terutama untuk memastikan token lama pada email yang sama diinvalidasi sesuai kebijakan Anda.
Kesalahan umum dan tips debugging
1. Token tersimpan plaintext
Ini kesalahan paling berbahaya. Periksa skema database dan alur pembuatan token. Jika Anda bisa membaca token reset mentah dari database, berarti desainnya belum aman.
2. Token selalu gagal diverifikasi
Periksa normalisasi input, encoding token, dan apakah token berubah saat dimasukkan ke URL atau template email. Karakter URL-safe lebih aman daripada string acak yang mengandung karakter khusus.
3. Reset berhasil, tetapi sesi lama tetap aktif
Ini bukan bug jika memang belum diimplementasikan. Namun dari sudut keamanan, sebaiknya setelah reset password, sesi lama dipertimbangkan untuk diakhiri.
4. Queue email tidak berjalan
Pastikan worker aktif, koneksi queue benar, dan job tidak gagal diam-diam. Pantau failed jobs dan logging mailer. Jangan debug dengan menambahkan token ke log.
5. Rate limit terlalu agresif
Jika banyak pengguna sah terkena throttle, periksa apakah mereka berbagi IP. Anda mungkin perlu menyesuaikan batas atau menambah kebijakan berbasis identifier dan IP secara lebih seimbang.
Checklist hardening reset password Laravel
- Gunakan token acak yang aman secara kriptografis.
- Simpan token dalam bentuk hash, bukan plaintext.
- Terapkan TTL yang singkat dan masuk akal.
- Invalidasi token setelah dipakai; idealnya dalam transaksi saat password diubah.
- Respons forgot-password harus konsisten untuk mencegah user enumeration.
- Terapkan rate limit per IP dan per identifier.
- Validasi email, token, dan password secara ketat tetapi tidak membocorkan informasi sensitif.
- Kirim email melalui queue.
- Jangan log token, password, atau URL reset lengkap.
- Minimalkan aset pihak ketiga pada halaman reset untuk mengurangi risiko referer leak.
- Catat audit log minimum: request, throttle, sukses, dan kegagalan penting tanpa data sensitif.
- Uji skenario expired, replay, brute force, dan non-enumeration.
Penutup
Laravel: reset password aman dengan token hash dan rate limit pada dasarnya adalah soal mengurangi permukaan serangan di setiap tahap alur. Token harus dibuat aman, disimpan sebagai hash, dibatasi TTL, dan dihapus atau ditandai terpakai setelah sukses. Di saat yang sama, endpoint harus tahan terhadap enumeration, brute force, dan flood email.
Jika Anda sudah memiliki fitur forgot-password yang “berjalan”, langkah berikutnya adalah audit implementasinya dengan checklist di atas. Dalam praktik, perbaikan kecil seperti menghapus logging token, menambah rate limit per identifier, dan memindahkan email ke queue sering memberi peningkatan keamanan yang signifikan tanpa perubahan arsitektur besar.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!