Rate Limiting untuk Autentikasi — Auth

Login endpoint adalah target utama: brute force, credential stuffing, dan enumeration. Rate limiting adalah lapisan pertahanan sebelum password hash bahkan…

Rate limiting untuk autentikasi membatasi berapa kali form login boleh dicoba dalam satu rentang waktu, dihitung per kunci: per akun dari satu IP, dan per IP. Lewat batas, server menolak dengan 429 tanpa memeriksa password sama sekali. Bayangin satpam di gerbang sekolah. Tamu yang salah menyebut nama guru boleh mencoba lagi, tapi setelah lima kali dalam semenit dia diminta menunggu di pos. Rombongan dari satu bus juga punya jatah sendiri. Dan tamu yang sudah tiga kali salah diminta menunjukkan kartu identitas dulu sebelum menyebut nama lagi: itu captcha.

🧪 Tebakan cepat: batas Portal Sekolah 5 percobaan per 60 detik per email|IP. Dari warnet, Siti mengirim login tiap 2 detik mulai pukul 07.00.00, dan semuanya gagal atau ditolak. Percobaan keenam, pukul 07.00.10, dijawab apa, dan Retry-After-nya berapa? Tebak dulu, jawabannya ada di bagian "Sliding window di setiap percobaan".

Kenapa login butuh batas sendiri

Login adalah satu-satunya endpoint yang menerima tebakan password. Tiap percobaan juga mahal untuk server: hash password memang sengaja lambat (lesson Password Hashing), jadi seribu percobaan semenit berarti seribu hash lambat semenit. Karena itu batasnya dipasang sebelum password diperiksa, dan jauh lebih ketat daripada endpoint lain. Sisi serangannya, brute force dan credential stuffing, dibahas di lesson Rate Limiting & Throttling di track Security. Di sini kita membangun batasnya untuk Portal Sekolah.

Dua kunci: per akun dan per IP

Kunci Batas di Lab Yang ditahan
email|IP, misalnya [email protected]|198.51.100.23 5 per 60 detik satu akun dicoba terus dari satu tempat
IP, misalnya 198.51.100.23 10 per 60 detik satu tempat mencoba banyak akun
akun saja, [email protected] captcha setelah 3 gagal dalam 15 menit satu akun dicoba dari banyak IP

Sliding window di setiap percobaan

function createRateLimiter(windowMs, maxRequests) {
  const hits = new Map(); // kunci -> [waktu percobaan, ...]
  return function allow(key, now) {
    const recent = (hits.get(key) || []).filter((t) => t > now - windowMs); // jendela berjalan
    recent.push(now); // dicatat, juga kalau nanti ditolak
    hits.set(key, recent);
    return recent.length <= maxRequests;
  };
}

const perEmailIp = createRateLimiter(60 * 1000, 5);
const perIp = createRateLimiter(60 * 1000, 10);

Jejak dari Lab, semuanya dari warnet 198.51.100.23:

Jam Percobaan email|IP di 60 detik Gagal akun Jawaban
07.00.00 Siti, password salah 1 1 401
07.00.02 Siti, password salah 2 2 401
07.00.04 Siti, password salah 3 3 401, "captcha_required": true
07.00.06 Siti, salah, tanpa captcha 4 3 422, password gak diperiksa
07.00.08 Siti, salah, dengan captcha 5 4 401
07.00.10 Siti, password benar 6 4 429, Retry-After: 52
07.01.02 Siti, benar, dengan captcha 5 0 200

Percobaan keenam dijawab 429 Too Many Requests dengan Retry-After: 52, walau password-nya benar. Itu jawaban tebakannya. Kenapa 52? Di jendelanya ada enam catatan: 0, 2, 4, 6, 8, 10 detik. Supaya percobaan berikutnya lolos, paling banyak empat catatan boleh tersisa, jadi catatan detik ke-2 harus keluar dulu. Itu terjadi di detik 62, dan 62 dikurangi 10 sama dengan 52.

Di latihan, createRateLimiter(60000, 3) dipanggil dengan kunci 'ip1' dan 'ip2', waktunya angka milidetik. Kunci yang berbeda punya catatan sendiri. Karena percobaan yang ditolak ikut dicatat, limit('ip1', 62500) masih false: catatan 3000, 4000, dan 62000 masih di jendela.

Jawaban 429 dan Retry-After

HTTP/1.1 429 Too Many Requests
Retry-After: 52
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 0
Content-Type: application/json

{"message": "Too Many Attempts."}

Captcha setelah 3 kegagalan

Kegagalan per akun dicatat dengan sliding window 15 menit dari lesson Account Recovery & Lockout. Begitu ada 3, jawaban 401 membawa "captcha_required": true, dan form menampilkan captcha. Percobaan berikutnya tanpa captcha dijawab 422 sebelum password diperiksa. Langkah ini mengikuti akunnya, jadi ganti IP gak menghapusnya. Siti sendiri tetap bisa masuk: cukup isi captcha. Login yang berhasil menghapus hitungannya.

function handleLogin({ email, password, captcha }, ip, now) {
  const okEmailIp = perEmailIp(`${email}|${ip}`, now); // dua-duanya selalu dipanggil,
  const okIp = perIp(ip, now);                         // supaya dua-duanya mencatat
  if (!okEmailIp || !okIp) return { status: 429 };
  if (recentFailures(email, now) >= 3 && !captcha) return { status: 422, captcha_required: true };
  if (!checkPassword(email, password)) {
    recordFailure(email, now);
    return { status: 401, captcha_required: recentFailures(email, now) >= 3 };
  }
  clearFailures(email);
  return { status: 200 };
}

Di Laravel

// app/Providers/AppServiceProvider.php, di method boot()
RateLimiter::for('login', function (Request $request) {
    $email = Str::lower((string) $request->input('email'));

    return [
        Limit::perMinute(5)->by('email-ip:'.$email.'|'.$request->ip()),
        Limit::perMinute(10)->by('ip:'.$request->ip()),
    ];
});

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

// LoginController@store, sebelum Auth::attempt: captcha setelah 3 kegagalan akun
$failures = 'login-failures:'.Str::lower($request->input('email'));
if (RateLimiter::attempts($failures) >= 3) {
    $captcha = Http::asForm()->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
        'secret' => config('services.turnstile.secret'),
        'response' => $request->input('cf-turnstile-response'),
        'remoteip' => $request->ip(),
    ]);
    if (! $captcha->json('success')) {
        return response()->json(['message' => 'Selesaikan captcha dulu.', 'captcha_required' => true], 422);
    }
}

⚠️ Jebakan yang sering ketemu

TL;DR: Login dibatasi sebelum password diperiksa, dengan dua kunci: email|IP yang ketat (5 per 60 detik di Lab) dan IP yang longgar (10 per 60 detik), karena per IP saja bisa dilewati dengan ganti IP dan menahan banyak orang sah di balik NAT. Hitungannya sliding window: percobaan di 60 detik terakhir, yang tepat di batas sudah keluar, dan yang ditolak ikut dicatat. Lewat batas: 429 Too Many Requests dengan Retry-After dalam detik. Per akun gak diblokir, tapi minta captcha setelah 3 kegagalan dalam 15 menit. Di Laravel: RateLimiter::for('login') berisi beberapa Limit::perMinute(...)->by(...), dipasang dengan throttle:login, plus RateLimiter::attempts, hit, dan clear untuk captcha.

Yang akan kamu pelajari