Rate limiting login dan OTP di Laravel adalah kontrol dasar yang wajib dipasang jika aplikasi Anda memiliki endpoint autentikasi publik. Tanpa pembatasan yang tepat, endpoint login rentan terhadap brute force dan credential stuffing, sementara endpoint OTP dan forgot password sering disalahgunakan untuk spam request, enumerasi akun, dan pemborosan biaya pengiriman SMS atau email.

Di Laravel modern, kombinasi RateLimiter, throttle middleware, cache backend yang andal, serta respons error yang tidak membocorkan status akun sudah cukup untuk membangun pertahanan yang praktis. Kuncinya bukan hanya memberi batas request, tetapi memilih key yang tepat per IP, per email, atau kombinasi keduanya, menerapkan backoff bertahap, dan mencatat sinyal abuse untuk audit serta monitoring.

Mengapa endpoint login, OTP, dan forgot password perlu dibatasi

Ketiga endpoint ini punya pola abuse yang berbeda, jadi kebijakan limit-nya sebaiknya tidak disamakan.

Login

  • Brute force: mencoba banyak password untuk satu akun.
  • Credential stuffing: mencoba pasangan email/password bocor dari layanan lain ke banyak akun.
  • Account enumeration: menebak apakah email terdaftar dari perbedaan pesan error atau timing respons.

OTP

  • Spam OTP request: memicu pengiriman SMS/email berulang ke satu target.
  • OTP guessing: menebak kode OTP dengan percobaan berulang.
  • Biaya operasional: pengiriman OTP dapat berdampak langsung ke biaya vendor.

Forgot password

  • Email flooding: mengirim email reset berulang ke alamat tertentu.
  • Enumerasi akun: membedakan akun valid dan tidak valid dari isi respons.

Karena karakter ancamannya berbeda, limit login biasanya fokus pada kombinasi akun dan IP, sedangkan OTP dan forgot password lebih ketat terhadap frekuensi per akun/tujuan dan cooldown antar request.

Prinsip implementasi rate limiting di Laravel

Laravel menyediakan dua mekanisme utama:

  • Throttle middleware untuk membatasi request di level route.
  • RateLimiter facade untuk mendefinisikan aturan limit bernama, sering dipakai lewat middleware throttle:nama-limiter.

Pendekatan yang baik adalah mendefinisikan limiter terpisah untuk setiap endpoint sensitif. Jangan memakai satu limit global untuk semua route autentikasi.

Pilih key limit sesuai ancaman

Pemilihan key adalah keputusan paling penting.

  • Per IP: efektif untuk menahan banjir request dari satu sumber.
  • Per email/username/phone: efektif untuk melindungi satu akun dari percobaan berulang.
  • Kombinasi akun + IP: baik untuk login karena menahan serangan tertarget sekaligus mengurangi dampak NAT.
  • Per device/session: membantu untuk flow OTP agar satu sesi tidak bisa meminta kode terlalu sering.

Umumnya, login sebaiknya menggunakan lebih dari satu sinyal, misalnya:

  • limit ketat untuk email + IP,
  • limit tambahan untuk IP saja,
  • opsional limit untuk email saja jika satu akun sering diserang dari banyak IP.

Trade-off IP-based vs account-based limit

IP-based limit mudah diterapkan dan efektif untuk request banjir dari satu alamat. Namun, ada beberapa kelemahan:

  • Pengguna di balik NAT atau jaringan kantor bisa berbagi satu IP publik sehingga pengguna sah ikut terkena limit.
  • Penyerang dengan botnet atau rotasi proxy dapat menghindari limit per IP.
  • Jika aplikasi berada di balik load balancer atau reverse proxy dan konfigurasi trusted proxy salah, IP klien bisa terbaca keliru.

Account-based limit lebih cocok untuk melindungi akun individual dari brute force dan OTP spam. Kelemahannya:

  • Penyerang bisa menyasar banyak akun berbeda dari satu IP tanpa cepat menyentuh limit per akun.
  • Jika respons berbeda untuk akun valid dan tidak valid, limit berbasis akun bisa memperparah account enumeration.

Kesimpulannya, untuk endpoint autentikasi publik, kombinasi IP-based dan account-based limit hampir selalu lebih aman daripada salah satu saja.

Backoff bertahap lebih efektif daripada blokir datar

Limit datar seperti “5 kali per menit” cukup untuk kasus sederhana, tetapi backoff bertahap lebih tahan terhadap abuse. Misalnya:

  • gagal 1-3 kali: delay kecil atau tetap normal,
  • gagal 4-5 kali: lock sementara singkat,
  • gagal berikutnya: cooldown lebih lama.

Backoff bertahap mengurangi efektivitas brute force tanpa langsung merusak pengalaman pengguna sah yang hanya salah ketik beberapa kali.

Contoh implementasi RateLimiter di Laravel

Berikut contoh definisi limiter di App\Providers\RouteServiceProvider atau lokasi yang sesuai dengan struktur proyek Anda. Fokusnya adalah memisahkan limit untuk login, request OTP, verifikasi OTP, dan forgot password.

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('login', function (Request $request) {
            $email = (string) $request->input('email');
            $ip = (string) $request->ip();

            return [
                Limit::perMinute(5)->by('login:email-ip:' . sha1(strtolower($email) . '|' . $ip)),
                Limit::perMinute(20)->by('login:ip:' . $ip),
            ];
        });

        RateLimiter::for('otp.request', function (Request $request) {
            $identifier = (string) ($request->input('email') ?: $request->input('phone'));
            $ip = (string) $request->ip();

            return [
                Limit::perMinute(3)->by('otp:req:acct:' . sha1(strtolower($identifier))),
                Limit::perMinute(10)->by('otp:req:ip:' . $ip),
            ];
        });

        RateLimiter::for('otp.verify', function (Request $request) {
            $identifier = (string) ($request->input('email') ?: $request->input('phone'));
            $ip = (string) $request->ip();

            return [
                Limit::perMinute(5)->by('otp:verify:acct-ip:' . sha1(strtolower($identifier) . '|' . $ip)),
                Limit::perMinute(20)->by('otp:verify:ip:' . $ip),
            ];
        });

        RateLimiter::for('password.forgot', function (Request $request) {
            $email = (string) $request->input('email');
            $ip = (string) $request->ip();

            return [
                Limit::perMinute(3)->by('forgot:email:' . sha1(strtolower($email))),
                Limit::perMinute(10)->by('forgot:ip:' . $ip),
            ];
        });
    }
}

Beberapa keputusan penting dari contoh di atas:

  • Key sensitif di-hash agar email/phone tidak tersimpan mentah di backend cache.
  • Limiter dipisah per endpoint karena pola abuse berbeda.
  • Login dan OTP verify memakai kombinasi akun dan IP karena serangannya biasanya tertarget.
  • OTP request dan forgot password punya limit akun yang lebih ketat untuk mencegah spam ke satu tujuan.

Menerapkan limiter ke route

use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\ForgotPasswordController;
use App\Http\Controllers\Auth\OtpController;
use Illuminate\Support\Facades\Route;

Route::post('/login', [LoginController::class, 'store'])
    ->middleware('throttle:login');

Route::post('/otp/request', [OtpController::class, 'request'])
    ->middleware('throttle:otp.request');

Route::post('/otp/verify', [OtpController::class, 'verify'])
    ->middleware('throttle:otp.verify');

Route::post('/forgot-password', [ForgotPasswordController::class, 'store'])
    ->middleware('throttle:password.forgot');

Jika aplikasi Anda memisahkan API dan web route, pastikan limiter diterapkan di route yang benar. Jangan berasumsi middleware global sudah cukup.

Menambahkan backoff bertahap pada login dan OTP

Rate limiter route berguna untuk batas umum, tetapi untuk backoff bertahap Anda biasanya perlu menambah logika di controller atau service autentikasi. Tujuannya adalah menghitung kegagalan beruntun dan memberi cooldown yang meningkat.

Contoh pola backoff login

Gunakan cache untuk menyimpan jumlah kegagalan dan waktu lock sementara. Kunci ideal biasanya berbasis akun + IP.

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Validation\ValidationException;

class LoginController
{
    public function store(Request $request)
    {
        $credentials = $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required', 'string'],
        ]);

        $email = strtolower($credentials['email']);
        $ip = (string) $request->ip();
        $key = 'auth:login:fail:' . sha1($email . '|' . $ip);
        $lockKey = 'auth:login:lock:' . sha1($email . '|' . $ip);

        if (Cache::has($lockKey)) {
            throw ValidationException::withMessages([
                'email' => ['Terlalu banyak percobaan. Coba lagi beberapa saat.'],
            ]);
        }

        if (! Auth::attempt($credentials, (bool) $request->boolean('remember'))) {
            $fails = Cache::increment($key);
            Cache::put($key, $fails, now()->addMinutes(15));

            $lockSeconds = match (true) {
                $fails >= 10 => 900,
                $fails >= 7 => 300,
                $fails >= 5 => 60,
                default => 0,
            };

            if ($lockSeconds > 0) {
                Cache::put($lockKey, true, now()->addSeconds($lockSeconds));
            }

            throw ValidationException::withMessages([
                'email' => ['Kredensial tidak valid.'],
            ]);
        }

        Cache::forget($key);
        Cache::forget($lockKey);
        $request->session()->regenerate();

        return response()->json(['message' => 'Login berhasil']);
    }
}

Mengapa pola ini berguna:

  • Kesalahan berturut-turut menaikkan waktu tunggu secara bertahap.
  • State disimpan di cache, sehingga lebih ringan dibanding menyimpan semua hitungan di database transaksi utama.
  • Reset setelah sukses menghindari menghukum pengguna yang akhirnya berhasil login.

Hindari menaruh backoff hanya di sisi frontend. Penyerang bisa memanggil endpoint langsung tanpa UI Anda.

Contoh cooldown untuk request OTP

Untuk OTP, praktik umum adalah memberi cooldown singkat antar permintaan selain batas per menit. Ini mencegah satu pengguna menekan tombol “kirim ulang” berkali-kali.

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class OtpController
{
    public function request(Request $request)
    {
        $data = $request->validate([
            'email' => ['nullable', 'email'],
            'phone' => ['nullable', 'string'],
        ]);

        $identifier = strtolower((string) ($data['email'] ?? $data['phone'] ?? ''));
        $cooldownKey = 'otp:cooldown:' . sha1($identifier);

        if (Cache::has($cooldownKey)) {
            return response()->json([
                'message' => 'Permintaan terlalu sering. Coba lagi beberapa saat.'
            ], 429);
        }

        Cache::put($cooldownKey, true, now()->addSeconds(60));

        // Buat OTP, simpan hash OTP, kirim via email/SMS queue.

        return response()->json([
            'message' => 'Jika data akun valid, kode verifikasi akan dikirim.'
        ]);
    }
}

Di sini ada dua lapis pertahanan:

  • Throttle middleware menangani volume global.
  • Cooldown per identifier mencegah resend beruntun meski limit per menit belum habis.

Respons error yang aman: jangan bocorkan status akun

Salah satu kesalahan paling umum adalah memberi pesan berbeda untuk:

  • email tidak terdaftar,
  • password salah,
  • akun nonaktif,
  • OTP tidak pernah dikirim.

Perbedaan ini memudahkan penyerang melakukan enumerasi akun. Untuk endpoint publik, gunakan pesan yang konsisten.

Contoh respons yang aman

  • Login gagal: Kredensial tidak valid.
  • Forgot password: Jika email terdaftar, tautan reset akan dikirim.
  • OTP request: Jika data akun valid, kode verifikasi akan dikirim.
  • OTP verify gagal: Kode verifikasi tidak valid atau kedaluwarsa.

Jika ada kebutuhan bisnis untuk menampilkan status lebih detail, pertimbangkan hanya setelah pengguna lolos langkah autentikasi lain atau di kanal yang tidak terbuka untuk publik.

Waspadai perbedaan timing

Meski isi pesan sudah seragam, perbedaan waktu respons yang terlalu jelas masih bisa dipakai untuk enumerasi. Misalnya, jika sistem hanya mengirim email untuk akun valid, respons akun valid mungkin jauh lebih lambat. Solusinya:

  • gunakan queue untuk pengiriman email/SMS,
  • selalu kembalikan respons generik secepat mungkin,
  • hindari query atau proses tambahan yang sangat berbeda antara akun valid dan tidak valid.

Menggabungkan session, cache, dan audit log

Rate limiting yang baik tidak berdiri sendiri. Laravel biasanya sudah memakai session dan cache; tambahkan audit log agar tim Anda bisa melihat pola serangan.

Peran cache

  • Menyimpan counter rate limit dan cooldown.
  • Menyimpan lock sementara akibat backoff.
  • Cocok untuk data sementara dengan TTL.

Untuk production, backend cache yang terpusat seperti Redis biasanya lebih tepat daripada file cache lokal, terutama jika aplikasi berjalan di banyak instance. Jika setiap instance punya cache terpisah, limit bisa tidak konsisten.

Peran session

  • Setelah login sukses, lakukan session()->regenerate() untuk mencegah session fixation.
  • Untuk flow OTP berbasis web, session bisa menyimpan state sementara seperti challenge ID atau tahapan verifikasi.
  • Session tidak cocok sebagai satu-satunya tempat menyimpan rate limit karena endpoint publik sering dipanggil sebelum session sah terbentuk, dan API stateless mungkin tidak menggunakan session sama sekali.

Peran audit log

Audit log tidak harus menyimpan semua request mentah. Fokuskan pada event yang berguna untuk investigasi:

  • login gagal berulang,
  • 429 terlalu banyak request,
  • permintaan OTP berulang ke identifier yang sama,
  • percobaan verifikasi OTP gagal berkali-kali,
  • forgot password yang memicu pola aneh dari satu IP atau ASN.

Catat setidaknya:

  • timestamp,
  • route atau action,
  • IP klien,
  • identifier yang sudah di-hash,
  • user agent ringkas,
  • hasil: success, fail, throttled, locked.

Hindari menyimpan password, OTP mentah, atau token sensitif di log.

Skenario abuse nyata dan keputusan limit yang relevan

Skenario 1: credential stuffing ke banyak akun dari satu IP

Gejalanya: satu IP mengirim banyak percobaan login dengan email berbeda. Mitigasi:

  • limit per IP lebih longgar dari per akun, tetapi tetap cukup membatasi volume,
  • audit log untuk mendeteksi jumlah email unik per IP,
  • opsional blokir sementara IP jika pola sangat agresif.

Skenario 2: brute force ke satu akun dari banyak IP

Gejalanya: satu email dicoba dari banyak sumber. Limit per IP saja tidak cukup. Mitigasi:

  • tambahkan limit per email atau email+IP,
  • aktifkan backoff bertahap per akun,
  • pertimbangkan step-up verification setelah ambang tertentu.

Skenario 3: spam OTP ke satu nomor

Gejalanya: OTP request berulang ke satu phone/email. Mitigasi:

  • cooldown per identifier,
  • batas harian atau periodik per identifier jika biaya pengiriman tinggi,
  • queue pengiriman agar endpoint tidak lambat dan lebih mudah diamati.

Skenario 4: forgot password dipakai untuk enumerasi akun

Gejalanya: banyak request reset ke daftar email. Mitigasi:

  • respons selalu generik,
  • limit per IP dan per email,
  • queue email,
  • monitor rasio email unik terhadap IP atau user-agent tertentu.

Masalah NAT, proxy, dan konfigurasi IP klien

Limit berbasis IP hanya berguna jika aplikasi membaca IP klien dengan benar. Pada deployment di balik reverse proxy, CDN, atau load balancer, salah konfigurasi trusted proxy bisa menyebabkan:

  • semua request terlihat berasal dari IP proxy,
  • rate limit salah sasaran,
  • audit log tidak akurat.

Pastikan aplikasi hanya mempercayai header forwarding dari proxy yang memang Anda kelola. Jangan sembarang mempercayai header seperti X-Forwarded-For dari internet publik tanpa proteksi infrastruktur yang benar.

Untuk lingkungan dengan banyak pengguna di satu IP publik, seperti kantor, kampus, atau operator seluler, hindari kebijakan yang terlalu agresif di level IP. Di situ, limit berbasis akun dan session biasanya lebih adil.

Pengujian manual yang wajib dilakukan

Setelah implementasi, jangan langsung menganggap semuanya aman. Uji skenario nyata secara manual.

1. Uji batas route

  1. Kirim request login salah berulang dari IP yang sama.
  2. Pastikan setelah ambang tercapai, respons menjadi 429 Too Many Requests atau masuk cooldown sesuai desain.
  3. Pastikan header dan pesan error tidak membocorkan informasi sensitif.

2. Uji kombinasi akun dan IP

  1. Coba email yang sama dari dua IP berbeda.
  2. Pastikan limit per akun tetap berfungsi jika memang Anda menambahkannya.
  3. Coba banyak email dari satu IP untuk melihat limit per IP bekerja.

3. Uji account enumeration

  1. Bandingkan respons untuk email valid dan tidak valid di login, OTP request, dan forgot password.
  2. Pastikan isi pesan, status code, dan waktu respons tidak terlalu berbeda.

4. Uji cooldown OTP

  1. Kirim request OTP dua kali cepat untuk identifier yang sama.
  2. Pastikan request kedua ditolak walau batas per menit belum habis.

5. Uji multi-instance

  1. Jika aplikasi berjalan di beberapa pod/server, kirim request ke instance berbeda.
  2. Pastikan rate limit tetap konsisten, yang berarti backend cache benar-benar terpusat.

Contoh uji dengan curl

for i in {1..7}; do
  curl -i -X POST https://app.example.com/login \
    -H 'Content-Type: application/json' \
    -d '{"email":"[email protected]","password":"salah"}'
  echo "\n---\n"
done

Amati kapan respons berubah menjadi throttled atau cooldown aktif. Lalu ulangi dengan email berbeda dari IP yang sama untuk melihat limiter per IP.

Metrik yang perlu dipantau

Rate limiting baru berguna jika dapat diamati. Metrik minimal yang sebaiknya ada:

  • Jumlah request per endpoint autentikasi: login, OTP request, OTP verify, forgot password.
  • Jumlah 429 per endpoint: indikator langsung bahwa limiter aktif.
  • Jumlah login gagal vs sukses: lonjakan gagal bisa menandakan stuffing.
  • Jumlah identifier unik per IP: berguna untuk mendeteksi credential stuffing.
  • Jumlah IP unik per identifier: berguna untuk mendeteksi brute force terdistribusi.
  • Volume OTP terkirim: penting untuk biaya dan penyalahgunaan.
  • Latency endpoint: pastikan limiter dan queue tidak menambah bottleneck yang tidak perlu.
  • Error cache backend: kegagalan Redis atau cache bisa membuat proteksi lumpuh atau tidak konsisten.

Jika memungkinkan, buat alert sederhana seperti:

  • 429 login meningkat tajam,
  • OTP request naik drastis dalam waktu singkat,
  • forgot password ke banyak email dari satu IP,
  • cache backend unavailable.

Kesalahan implementasi yang sering terjadi

  • Hanya limit per IP sehingga serangan terdistribusi tetap lolos.
  • Hanya limit per akun sehingga satu IP bisa menyerang banyak akun.
  • Cache lokal per server pada deployment multi-instance, membuat limit tidak konsisten.
  • Pesan error berbeda untuk akun valid dan tidak valid.
  • Menyimpan identifier mentah di key cache atau log tanpa hash atau minimisasi data.
  • Tidak menghapus counter gagal setelah login sukses sehingga pengguna sah tetap terus terkunci.
  • Tidak menguji trusted proxy sehingga semua user berbagi IP yang sama menurut aplikasi.

Checklist deployment aman

  • Gunakan RateLimiter terpisah untuk login, OTP request, OTP verify, dan forgot password.
  • Terapkan kombinasi limit per IP dan per akun/identifier sesuai ancaman.
  • Tambahkan backoff bertahap untuk login dan verifikasi OTP.
  • Tambahkan cooldown resend untuk OTP dan forgot password.
  • Gunakan cache terpusat yang andal untuk environment multi-instance.
  • Hash email/phone saat dipakai sebagai key atau dicatat di audit log.
  • Pastikan respons endpoint publik tidak membocorkan status akun.
  • Konfigurasikan trusted proxy dengan benar agar IP klien akurat.
  • Gunakan queue untuk pengiriman email/SMS agar timing respons lebih konsisten.
  • Pantau metrik 429, login gagal, volume OTP, dan error cache.
  • Lakukan uji manual untuk skenario brute force, stuffing, spam OTP, dan enumerasi akun.

Penutup

Untuk mencegah brute force, credential stuffing, dan spam request di Laravel, jangan berhenti pada satu middleware throttle generik. Rate limiting login dan OTP di Laravel akan jauh lebih efektif jika Anda memisahkan kebijakan per endpoint, memakai key per akun dan per IP secara bersamaan, menambahkan backoff bertahap, serta menjaga respons tetap generik agar status akun tidak bocor.

Dengan kombinasi RateLimiter, cache terpusat, session yang dikelola benar, dan audit log yang cukup, Anda bisa membangun pertahanan yang praktis tanpa membuat pengguna sah terlalu sering terkunci. Fokus utamanya adalah memilih sinyal pembatasan yang tepat dan memastikan perilakunya konsisten di production.