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, danRetry-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 |
- Batas per IP saja gak cukup. Satu orang bisa berganti IP lewat VPN atau proxy, sementara banyak orang sah bisa berbagi satu IP: 30 siswa di lab komputer sekolah keluar ke internet lewat satu IP publik (NAT). Batas per IP harus longgar, dan yang ketat adalah
email|IP. - Batas per akun gak memblokir, cuma menaikkan langkah: minta captcha. Kalau batas per akun memblokir, siapa pun yang tahu email Siti bisa membuat Siti gak bisa masuk, masalah yang sama dengan kunci permanen di lesson Account Recovery & Lockout.
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);
filter((t) => t > now - windowMs): yang dihitung cuma percobaan di 60 detik terakhir, mundur dari sekarang. Percobaan yang waktunya tepatnow - windowMssudah keluar.recent.push(now)sebelum memutuskan: percobaan yang ditolak ikut dicatat. Klien yang terus menembak tanpa menunggu tetap tertahan, klien yang menunggu sesuaiRetry-Aftermasuk lagi.recent.length <= maxRequests: percobaan kelima masih lolos, yang keenam ditolak.- Bandingkan dengan fixed window: satu penghitung per menit kalender (07.00.00 sampai 07.00.59) yang kembali ke nol tiap menit baru. Lima percobaan di detik ke-59 dan lima lagi di detik ke-00 menit berikutnya lolos semua: 10 percobaan dalam 2 detik, dua kali batasnya. Sliding window menghitung 60 detik terakhir dari sekarang, jadi celah itu gak ada.
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."}
429 Too Many Requestsadalah status khusus untuk batas yang terlewati. Bukan403(izin) dan bukan503(server sedang sakit).Retry-Afterberisi berapa detik klien harus menunggu. Form login Portal Sekolah membacanya dan menampilkan hitung mundur, bukan tombol yang bisa terus diklik.- Jawaban ini sama untuk email terdaftar dan gak, jadi tetap gak membocorkan siapa yang punya akun.
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);
}
}
RateLimiter::for('login', ...)memberi nama pada aturan, danthrottle:loginmemasangnya di route. Array berisi duaLimitdiperiksa berurutan. Awalanemail-ip:danip:membuat kuncinya gak pernah bertabrakan.- Lewat batas, Laravel menjawab
429dengan{"message": "Too Many Attempts."}dan headerRetry-After,X-RateLimit-Limit,X-RateLimit-Remaining. - Dua beda dengan
createRateLimiterdi atas: middlewarethrottlegak mencatat request yang ditolak, dan RateLimiter Laravel memulai jendelanya di percobaan pertama lalu menghapus hitungannya sekaligus. Hasilnya mirip, angkaRetry-After-nya bisa beda. - Setelah
Auth::attemptgagal, panggilRateLimiter::hit($failures, 15 * 60). Kalau berhasil,RateLimiter::clear($failures). Captcha-nya Cloudflare Turnstile: server memeriksa token dari widget kesiteverifydan membacasuccess. - Server lebih dari satu? Hitungannya disimpan di cache bersama seperti Redis, supaya semua server membaca angka yang sama.
⚠️ Jebakan yang sering ketemu
- Batas per IP saja. Satu orang ganti IP, sementara satu sekolah di balik NAT ikut tertahan. Kunci ketatnya
email|IP, batas per IP longgar. - Memblokir per akun. Siapa pun bisa mengunci pemiliknya. Per akun cukup minta captcha.
- Memeriksa password dulu, baru batasnya. Hash yang lambat tetap jalan untuk setiap percobaan. Batas dicek paling awal.
- Lupa mencatat percobaan yang ditolak, padahal latihan memintanya:
limit('ip1', 62500)harusfalse.
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.