Menahan serangan brute-force dan DoS dengan Symfony RateLimiter: konsep limiter (FixedWindow, SlidingWindow, TokenBucket), throttling login per-user & per-IP, membatasi API per klien, caching backend Redis, serta mitigasi di level aplikasi dan infrastruktur.

Episode 19 menutup jalan autentikasi. Episode 20 menutup satu pintu lagi yang sering diabaikan: laju serangan. Brute-force login, spam endpoint, dan DoS (Denial of Service) bekerja dengan satu asumsi: server akan melayani permintaan apa pun sepuasnya. Rate limiting mematahkan asumsi itu.
Mengapa ini penting? Karena pertahanan tanpa rate limiting seperti pintu terkunci tanpa penghitung gagal — tetap bisa dirusak dengan mencoba terus-menerus. Login yang di-rate limit bukan hanya mencegah brute-force, tapi juga melindungi database dari beban query yang tidak perlu.
Install component:
composer require symfony/rate-limiterLimiter diartikan sebagai strategi menghitung konsumsi dalam interval waktu. Tiga strategi bawaan:
| Strategi | Cara kerja | Cocok untuk |
|---|---|---|
| FixedWindowLimiter | Reset counter di akhir window tetap | Sederhana, per menit/jam |
| SlidingWindowLimiter | Window bergeser mulus | Batas yang lebih adil |
| TokenBucketLimiter | Token terisi berkala, burst diizinkan | API dengan burst |
Definisikan di config/packages/rate_limiter.yaml:
framework:
rate_limiter:
anonymous_api:
policy: 'fixed_window'
limit: 30
interval: '1 minute'
authenticated_api:
policy: 'token_bucket'
limit: 100
rate: { interval: '1 minute', amount: 10 }
login_attempts:
policy: 'sliding_window'
limit: 5
interval: '15 minutes'Tiap limiter punya limit (kuota) dan interval (jendela). Token bucket menambahkan rate — kecepatan pengisian token.
Serangan brute-force menyasar route login. Proteksi bawaan paling sederhana adalah login_throttling:
security:
firewalls:
main:
login_throttling:
max_attempts: 5
interval: '15 minutes'Tanpa menulis kode, 5 percobaan gagal dalam 15 menit memicu penolakan. Namun throttling default membatasi per IP — penyerang yang memakai banyak IP bisa menghindarinya. Kombinasikan dengan per-user limiter:
use Symfony\Component\RateLimiter\RateLimiterFactory;
public function login(
Request $request,
RateLimiterFactory $loginAttemptsLimiter,
): Response
{
$identifier = $request->getUser() ?? $request->getClientIp();
$limiter = $loginAttemptsLimiter->create($identifier);
if (!$limiter->consume()->isAccepted()) {
throw $this->createTooManyRequestsHttpException(
60, 'Terlalu banyak percobaan. Coba lagi dalam 1 menit.'
);
}
// lanjut proses login...
}Kombinasi keduanya: IP dibatasi oleh firewall (login_throttling), user dibatasi oleh limiter custom — penyerang yang ganti IP tetap terhadang di limiter per-user.
Untuk endpoint API (episode 13), batasi konsumsi per klien. Integrasi paling rapi lewat listener:
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\RateLimiter\RateLimiterFactory;
#[AsEventListener(event: RequestEvent::class, priority: 40)]
final class ApiRateLimitListener
{
public function __construct(
private readonly RateLimiterFactory $anonymousApiLimiter,
private readonly RateLimiterFactory $authenticatedApiLimiter,
) {
}
public function __invoke(RequestEvent $event): void
{
$request = $event->getRequest();
if (!str_starts_with($request->getPathInfo(), '/api/')) {
return;
}
$user = $this->getUser();
$factory = $user ? $this->authenticatedApiLimiter : $this->anonymousApiLimiter;
$identifier = $user ? (string) $user->getId() : (string) $request->getClientIp();
if (!$factory->create($identifier)->consume()->isAccepted()) {
throw new TooManyRequestsHttpException(
retryAfter: 60,
message: 'Batas request API tercapai.',
);
}
}
}Pola penting di sini: identifier berbeda untuk user vs anonymous. User dibatasi per akun; klien yang belum login dibatasi per IP. Lihat juga retryAfter — ini menghasilkan header Retry-After yang informatif.
Rate limiter menyimpan state. Default memakai cache.app, tetapi untuk production multi-instance (episode 24), state harus dibagi lintas server — kalau tidak, 5 percobaan di server A + 5 di server B = 10 percobaan lolos. Kunci Redis:
framework:
rate_limiter:
login_attempts:
policy: 'sliding_window'
limit: 5
interval: '15 minutes'
cache_pool: cache.rate_limiter
cache:
pools:
cache.rate_limiter:
adapter: cache.adapter.redisDengan ini, semua instance membaca counter yang sama dari Redis — batas benar-benar global.
Warning
Rate limiting di level aplikasi bukan pengganti proteksi level infrastruktur. CDN/WAF (Cloudflare, AWS WAF) dan reverse proxy dengan modul seperti fail2ban tetap dibutuhkan untuk jilatan banjir besar sebelum traffic sampai ke PHP. RateLimiter adalah lapisan kedua — yang membedakan bot cerdas dari ledakan biasa, di sisi aplikasi.
Saat limiter menolak, balas dengan kode dan header yang sesuai HTTP:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1723802400Kode 429 memberi tahu klien (dan WAF di depannya) bahwa request tertahan sementara — bukan kegagalan permanen. Header Retry-After memberi instruksi kapan boleh mencoba lagi.
per IP untuk endpoint yang dipakai banyak user di belakang NAT (kantor) akan memblokir semua; pilih identifier sesuai konteks.cache_pool memory) di env test.reset() — tidak ada reset otomatis saat window berubah; konsumsi dihitung per key di window aktif.Pada episode 20 ini, kalian telah menahan laju serangan.
Inti yang harus dibawa pulang:
login_throttling bawaan untuk brute-force login per IP.429 + Retry-After yang benar.Di episode 21 selanjutnya kita kembali ke database untuk skala besar: Advanced Doctrine — QueryBuilder & DQL, hydration modes, pagination besar, dan menyelesaikan masalah N+1 dengan fetch join. Sampai jumpa di episode 21!