Menebak password satu-satu itu murah untuk script dan mahal untuk manusia. Serangan brute force hidup dari aritmetika sederhana: seribu percobaan per menit dengan kamusi password lima juta entri berarti akun-akun lemah jatuh dalam hitungan jam. Rate limiting membalik ekonomi ini — setiap percobaan dikenakan biaya waktu. Section API Development sudah membahas throttling untuk endpoint API; chapter ini menerapkannya sebagai alat keamanan seluruh aplikasi: mendefinisikan limiter per user dan per IP, memasangnya di route login, dan menyusun tier untuk endpoint ringan serta berat. Semua contoh diuji dengan percobaan beruntun sungguhan sampai status 429 muncul.

Mendefinisikan Limiter dengan RateLimiter::for #

Definisi limiter berkumpul di method boot() pada app/Providers/AppServiceProvider.php — semuanya terdokumentasi resmi di Rate Limiters . Setiap limiter bernama, dan nilainya bisa berupa beberapa Limit sekaligus:

php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
    RateLimiter::for('login', function (Request $request) {
        return Limit::perMinute(3)
            ->by($request->input('email') ?? $request->ip());
    });

    RateLimiter::for('aksi', function (Request $request) {
        return Limit::perMinute(60)
            ->by($request->user()?->id ?: $request->ip());
    });
}

Method by() menentukan kunci penghitung — identitas apa yang dihitung. Pola $request->user()?->id ?: $request->ip() adalah konvensi penting: user yang sudah login dihitung per akun, sedangkan tamu tanpa sesi dihitung per alamat IP sehingga satu NAT kantor tidak langsung saling memblokir. Untuk route login yang belum mengenal user, kunci yang tepat adalah email yang dikirim — penyerang yang menembak satu akun terblokir tanpa melumpuhkan seluruh pengunjung.

Memasang throttle di Route Login #

Middleware throttle menerima nama limiter yang kita definisikan. Route login lengkapnya:

php
use Illuminate\Support\Facades\Auth;

Route::post('/login', function (Request $request) {
    if (! Auth::attempt($request->only('email', 'password'))) {
        return response()->json([
            'message' => 'Kredensial tidak cocok dengan data kami.',
        ], 422);
    }

    return response()->json(['message' => 'Login berhasil.']);
})->middleware(['web', 'throttle:login']);

Kita tembak lima kali beruntun dengan password berbeda untuk akun [email protected]:

text
=== brute force login: 5 percobaan beruntun ===
percobaan 1 → HTTP 422 | Retry-After: - | Kredensial tidak cocok dengan data kami.
percobaan 2 → HTTP 422 | Retry-After: - | Kredensial tidak cocok dengan data kami.
percobaan 3 → HTTP 422 | Retry-After: - | Kredensial tidak cocok dengan data kami.
percobaan 4 → HTTP 429 | Retry-After: 59 | Too Many Attempts.
  header X-RateLimit-Limit    : 3
  header X-RateLimit-Remaining: 0
percobaan 5 → HTTP 429 | Retry-After: 59 | Too Many Attempts.

Tiga percobaan pertama dijawab 422 seperti biasa. Percobaan keempat ditolak 429 Too Many Attempts dengan header Retry-After: 59 — klien tahu harus menunggu 59 detik. Header X-RateLimit-Limit dan X-RateLimit-Remaining memberi tahu klien yang santun tentang kuotanya. Satu detail yang sering mengejutkan: limiter memblokir akun yang ditembak, bukan si penembak. Percobaan keempat dengan password yang benar pun ikut ditolak:

text
=== kredensial BENAR tetap ditolak saat limit aktif ===
percobaan ke-4 dengan password BENAR → HTTP 429 | Too Many Attempts.

Perilaku ini disengaja — korban yang password-nya sedang di-brute force tidak bisa dipaksa masuk berulang, dan penyerang tidak bisa menguji apakah tebakannya benar. Budi yang sebenarnya cukup menunggu satu menit atau memakai alur password reset .

Tier per Endpoint dan Respons Kustom #

Endpoint tidak setara: mencari produk jauh lebih murah daripada menghitung laporan besar. Karena itu definisikan tier terpisah — dan jika perlu, respons kustom lewat method response():

php
RateLimiter::for('berat', function (Request $request) {
    return Limit::perMinute(2)
        ->by($request->user()?->id ?: $request->ip())
        ->response(function () {
            return response()->json([
                'message' => 'Kuota laporan berat Anda habis. Coba lagi dalam 1 menit.',
            ], 429);
        });
});

Kita uji endpoint yang memakai throttle:berat dari akun yang sudah login:

text
=== tier berat: 2x per menit + respons kustom ===
GET /laporan/berat 1 → HTTP 200 | Laporan berat selesai dihitung.
GET /laporan/berat 2 → HTTP 200 | Laporan berat selesai dihitung.
GET /laporan/berat 3 → HTTP 429 | Kuota laporan berat Anda habis. Coba lagi dalam 1 menit.

Respons kustom menggantikan halaman error bawaan — berguna agar klien API Anda menerima pesan berformat konsisten. Untuk limiter sederhana tanpa nama, middleware throttle juga menerima bentuk singkat:

BentukArtiCocok untuk
throttle:6060 permintaan/menit, per user atau IPhalaman umum
throttle:3,13 percobaan, jendela 1 menitroute login
throttle:loginlimiter bernama loginlogin, 2FA
throttle:beratlimiter bernama beratlaporan, ekspor

Catatan Multi-Server dan Batasannya #

Dua hal penting sebelum menyalakan limiter di produksi. Pertama, penghitung limiter disimpan di cache — dan CACHE_STORE bawaan aplikasi satu server memakai driver database. Bila aplikasi berjalan di beberapa server di balik load balancer, pindahkan cache store (termasuk penghitung rate limit) ke penyimpanan terpusat seperti Redis agar semua server berbagi hitungan yang sama. Kedua, pahami apa yang tidak diselesaikan rate limiting:

  • Password yang benar tetap lolos — satu tebakan tepat di percobaan pertama tidak terblokir. Pasangkan dengan password yang di-hash kuat .
  • Penyerang berpindah IP — jaringan bot punya ribuan alamat; limiter per IP memperlambat, bukan menghentikan. Kunci per akun atau per email lebih tahan.
  • Lalu lintas sah ikut terkena — nilai terlalu ketat menghukum pengguna sungguhan. Pantau header X-RateLimit-Remaining di log Anda.

Sebelum rate limiting menyelamatkan aplikasi dari penyerang luar, ada satu celah yang datang dari dalam tim sendiri: kredensial yang ikut tercommit ke repositori. Kita rapikan itu di chapter berikutnya: Secret Management .