CodeIgniter 4 rate limit login dan reset password perlu dirancang sebagai kontrol berlapis, bukan sekadar membatasi jumlah request per menit. Endpoint login rawan brute force, sedangkan endpoint reset password sering disalahgunakan untuk spam email, enumerasi akun, dan percobaan token berulang.

Pendekatan yang aman di production biasanya menggabungkan throttle per IP, pembatasan per email atau identifier, cooldown sementara setelah gagal beruntun, pesan respons yang tidak membocorkan validitas akun, serta logging audit yang cukup untuk investigasi. Di CodeIgniter 4, ini bisa dibangun dengan filter/middleware, service cache atau database untuk counter, dan alur reset token yang ketat.

Model ancaman yang perlu ditangani

Sebelum menulis kode, tentukan abuse yang ingin dibatasi. Untuk login dan reset password, ancaman utamanya biasanya:

  • Brute force login berbasis IP: satu alamat IP mencoba banyak kombinasi password.
  • Credential stuffing: banyak akun dicoba dari satu atau banyak IP menggunakan password bocor.
  • User enumeration: penyerang mencoba mengetahui apakah email terdaftar dari perbedaan respons.
  • Email flooding: endpoint reset password dipakai untuk membanjiri inbox korban.
  • Token guessing atau token replay: token reset dicoba berkali-kali atau dipakai ulang.

Karena pola serangan berbeda, satu dimensi rate limit saja hampir selalu kurang. Limit per IP membantu menahan brute force sederhana, tetapi lemah jika penyerang memakai banyak IP. Limit per email membantu melindungi akun tertentu, tetapi berisiko mengganggu user sah jika terlalu agresif. Kombinasi keduanya biasanya lebih seimbang.

Throttle, cooldown, dan lockout sementara

Throttle

Throttle membatasi jumlah request dalam jendela waktu tertentu, misalnya maksimal 5 percobaan login per menit per IP. Tujuannya mengurangi laju serangan tanpa harus memblokir total.

Kelebihan: sederhana, cocok untuk trafik normal, mudah diterapkan di level filter.
Kekurangan: kurang efektif terhadap serangan terdistribusi dari banyak IP.

Cooldown

Cooldown memberi jeda sementara setelah sejumlah kegagalan beruntun pada target tertentu, misalnya email yang sama gagal login 5 kali lalu harus menunggu 10 menit sebelum mencoba lagi. Ini efektif untuk melindungi satu akun dari tebakan password berulang.

Kelebihan: memperlambat penyerang pada akun yang ditargetkan.
Kekurangan: jika terlalu ketat, bisa dipakai untuk mengganggu user sah.

Lockout sementara

Lockout sementara adalah blokir total untuk periode tertentu setelah ambang tertentu terlampaui. Gunakan dengan hati-hati. Pada endpoint login, lockout permanen atau terlalu lama sering menjadi vektor denial of service terhadap akun korban.

Praktik yang lebih aman adalah lockout sementara pendek dengan pesan umum, lalu eskalasi ke verifikasi tambahan atau notifikasi keamanan jika pola serangan berlanjut.

Desain rate limit yang layak untuk production

1. Per IP

Cocok untuk menahan spam dan brute force dari satu sumber. Contoh kebijakan:

  • Login: maksimal N request per menit per IP.
  • Reset password: maksimal N request per 15 menit per IP.

Gunakan jika aplikasi menghadap internet publik. Namun jangan mengandalkan ini saja.

2. Per email atau identifier

Pembatasan berdasarkan email, username, atau identifier lain berguna untuk melindungi akun spesifik. Karena nilai ini sensitif, sebaiknya simpan key rate limit dalam bentuk hash, bukan plaintext, agar log atau storage tidak memperlihatkan alamat email secara langsung.

Contoh kebijakan:

  • Login gagal: maksimal 5 kegagalan per email dalam 10 menit.
  • Reset password: maksimal 3 permintaan aktif per email dalam 30 menit.

3. Kombinasi IP + email

Ini sering paling efektif. Misalnya, cek tiga lapisan sekaligus:

  1. Throttle global per IP.
  2. Throttle per email.
  3. Cooldown untuk pasangan IP + email setelah kegagalan berulang.

Kombinasi ini membantu membedakan dua kasus: satu IP menyerang banyak akun, atau satu akun diserang dari banyak IP.

4. Global safeguard

Untuk endpoint reset password, pertimbangkan safeguard global agar sistem mail tidak dibanjiri. Misalnya, jika antrian email atau request reset melonjak tajam, turunkan kecepatan pengiriman atau masukkan ke antrian dengan pembatasan tambahan.

Arsitektur implementasi di CodeIgniter 4

Di CodeIgniter 4, pola umum yang bersih adalah:

  • Filter sebelum controller untuk memeriksa rate limit awal berdasarkan endpoint dan request.
  • Service khusus untuk operasi counter: increment, cek ambang, set TTL, dan baca status cooldown/lockout.
  • Controller tetap menangani validasi input, autentikasi, dan reset token.
  • Audit logging setelah keputusan penting: request ditolak, login gagal, reset diminta, token invalid, token sukses dipakai.

Pemisahan ini penting agar logika rate limit tidak tersebar di banyak controller dan lebih mudah diuji.

Pilih penyimpanan counter: cache atau database?

Cache cocok untuk counter rate limit karena operasi increment dan TTL lebih murah. Ideal jika aplikasi memiliki backend cache bersama antar instance.

Database lebih mudah diinspeksi dan bisa dipakai jika infrastruktur sederhana, tetapi lebih berat untuk request frekuensi tinggi dan perlu desain yang hati-hati agar tidak menjadi bottleneck.

Trade-off ringkas:

  • Cache: cepat, TTL alami, cocok untuk counter sementara; perlu perhatian pada konsistensi dan shared store jika aplikasi horizontal scaling.
  • Database: audit lebih mudah, tetapi operasi update counter bisa mahal; TTL perlu dikelola sendiri dengan kolom expired_at atau job pembersihan.

Jika Anda menjalankan beberapa instance aplikasi, hindari menyimpan counter hanya di memory lokal proses. Rate limit akan tidak konsisten antar server.

Contoh service rate limiter sederhana

Berikut contoh service generik yang bisa memakai cache backend CodeIgniter 4. Implementasi ini sengaja sederhana: menyimpan counter dan expiry dalam satu key. Untuk beban tinggi, backend yang mendukung increment atomik akan lebih baik.

<?php

namespace App\Libraries;

use CodeIgniter\Cache\CacheInterface;

class RateLimiterService
{
    public function __construct(private CacheInterface $cache)
    {
    }

    public function hit(string $key, int $ttl): array
    {
        $data = $this->cache->get($key);
        $now  = time();

        if (! is_array($data) || ($data['expires_at'] ?? 0) <= $now) {
            $data = [
                'count'      => 0,
                'expires_at' => $now + $ttl,
            ];
        }

        $data['count']++;
        $remainingTtl = max(1, $data['expires_at'] - $now);
        $this->cache->save($key, $data, $remainingTtl);

        return [
            'count'      => $data['count'],
            'expires_at' => $data['expires_at'],
            'retry_after' => $remainingTtl,
        ];
    }

    public function get(string $key): ?array
    {
        $data = $this->cache->get($key);
        if (! is_array($data)) {
            return null;
        }

        if (($data['expires_at'] ?? 0) <= time()) {
            return null;
        }

        return $data;
    }

    public function clear(string $key): void
    {
        $this->cache->delete($key);
    }
}

Catatan penting: contoh di atas belum menjamin atomisitas increment pada semua backend cache. Untuk production dengan trafik tinggi, pilih backend dan pola penyimpanan yang mendukung operasi increment atomik, atau pindahkan counter ke mekanisme yang lebih kuat.

Filter CI4 untuk rate limit login

Filter ini memeriksa throttle per IP sebelum controller login dijalankan. Jika ingin lebih ketat, tambahkan juga key per email yang dibaca dari body request.

<?php

namespace App\Filters;

use App\Libraries\RateLimiterService;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Filters\FilterInterface;

class LoginRateLimitFilter implements FilterInterface
{
    public function before(RequestInterface $request, $arguments = null)
    {
        $limiter = service('loginRateLimiter');
        $ip      = $request->getIPAddress();
        $key     = 'login:ip:' . hash('sha256', $ip);

        $result = $limiter->hit($key, 60);

        if ($result['count'] > 10) {
            return service('response')
                ->setStatusCode(429)
                ->setJSON([
                    'status'  => 'error',
                    'message' => 'Terlalu banyak percobaan. Silakan coba lagi beberapa saat.',
                ]);
        }
    }

    public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
    {
    }
}

Daftarkan filter di konfigurasi filter aplikasi, lalu terapkan pada route login. Jika endpoint login menerima email, Anda dapat menambahkan key seperti login:email:{hash(email_normalized)} dan login:pair:{hash(ip|email)}.

Normalisasi input sebelum membuat key

Untuk email, selalu normalisasikan lebih dulu:

  • trim whitespace
  • ubah ke lowercase
  • validasi format

Tanpa normalisasi, [email protected] dan [email protected] bisa dihitung sebagai target berbeda.

Implementasi login yang aman dari enumeration

Kesalahan umum pada login adalah memberi respons berbeda antara “email tidak terdaftar” dan “password salah”. Ini memudahkan penyerang membangun daftar akun valid.

Gunakan satu pesan umum untuk semua kegagalan autentikasi:

<?php

public function login()
{
    $rules = [
        'email'    => 'required|valid_email|max_length[254]',
        'password' => 'required|string|max_length[255]',
    ];

    if (! $this->validate($rules)) {
        return $this->response->setStatusCode(422)->setJSON([
            'status'  => 'error',
            'message' => 'Input tidak valid.',
            'errors'  => $this->validator->getErrors(),
        ]);
    }

    $email = strtolower(trim((string) $this->request->getPost('email')));
    $password = (string) $this->request->getPost('password');

    $user = $this->userModel->findByEmail($email);

    $authenticated = $user && password_verify($password, $user['password_hash']);

    if (! $authenticated) {
        $this->auditLog('auth.login.failed', [
            'email_hash' => hash('sha256', $email),
            'ip'         => $this->request->getIPAddress(),
            'ua'         => (string) $this->request->getUserAgent(),
        ]);

        return $this->response->setStatusCode(401)->setJSON([
            'status'  => 'error',
            'message' => 'Kredensial tidak valid.',
        ]);
    }

    return $this->response->setJSON([
        'status'  => 'success',
        'message' => 'Login berhasil.',
    ]);
}

Respons ini tidak membocorkan apakah akun ada atau tidak. Jika ingin lebih aman lagi, pertimbangkan konsistensi waktu respons agar tidak ada perbedaan mencolok antara user yang ada dan tidak ada, walau dalam praktik web biasa ini tidak selalu mudah dibuat identik.

Kapan counter direset?

Jangan otomatis mereset semua counter hanya karena satu login berhasil, terutama counter per IP yang mungkin mewakili serangan terhadap banyak akun. Yang lazim:

  • Counter pasangan IP + email boleh dikurangi atau dihapus setelah login sukses akun tersebut.
  • Counter global per IP sebaiknya tetap mengikuti jendela waktunya.

Reset password: desain aman dan anti abuse

Endpoint reset password sering dianggap aman karena tidak mengubah password secara langsung. Padahal endpoint ini bisa dipakai untuk spam, enumerasi akun, dan percobaan token.

Prinsip dasar yang perlu diterapkan

  • Respons selalu generik: jangan pernah memberi tahu apakah email terdaftar.
  • Rate limit per IP dan per email: cegah spam ke target tertentu.
  • Token acak kuat: jangan token yang mudah ditebak.
  • Simpan hash token, bukan token mentah: jika database bocor, token aktif tidak langsung bisa dipakai.
  • TTL pendek: token hanya berlaku singkat.
  • Single use: token hanya bisa dipakai sekali.
  • Invalidasi token lama: ketika token baru dibuat, token lama untuk user yang sama harus dinonaktifkan.

Respons aman untuk request reset password

Gunakan pola respons seperti ini untuk email valid maupun tidak valid:

{
  "status": "success",
  "message": "Jika akun terdaftar, instruksi reset password akan dikirim ke email tersebut."
}

Pesan ini penting untuk mencegah enumeration. Hindari pesan seperti “Email tidak ditemukan” atau “Email berhasil dikirim” hanya untuk akun yang benar-benar ada.

Contoh alur request reset password

<?php

public function requestPasswordReset()
{
    $rules = [
        'email' => 'required|valid_email|max_length[254]',
    ];

    if (! $this->validate($rules)) {
        return $this->response->setStatusCode(422)->setJSON([
            'status'  => 'error',
            'message' => 'Input tidak valid.',
            'errors'  => $this->validator->getErrors(),
        ]);
    }

    $email = strtolower(trim((string) $this->request->getPost('email')));
    $user  = $this->userModel->findByEmail($email);

    $this->auditLog('auth.password_reset.requested', [
        'email_hash' => hash('sha256', $email),
        'ip'         => $this->request->getIPAddress(),
    ]);

    if ($user) {
        $rawToken = bin2hex(random_bytes(32));
        $tokenHash = hash('sha256', $rawToken);
        $expiresAt = date('Y-m-d H:i:s', time() + 1800);

        $this->passwordResetModel->invalidateActiveTokensByUserId((int) $user['id']);
        $this->passwordResetModel->create([
            'user_id'      => (int) $user['id'],
            'token_hash'   => $tokenHash,
            'expires_at'   => $expiresAt,
            'used_at'      => null,
            'requested_ip' => $this->request->getIPAddress(),
        ]);

        // Kirim raw token via email, jangan simpan token mentah di database.
        // URL contoh: /reset-password?token=...
    }

    return $this->response->setJSON([
        'status'  => 'success',
        'message' => 'Jika akun terdaftar, instruksi reset password akan dikirim ke email tersebut.',
    ]);
}

Alur ini tetap aman walau email tidak terdaftar, karena responsnya sama. Logging tetap dilakukan agar abuse bisa dianalisis.

Validasi token reset password

Saat user mengirim token dan password baru:

  1. Validasi format token dan password baru.
  2. Hash token yang diterima.
  3. Cari token aktif berdasarkan hash.
  4. Pastikan belum dipakai dan belum kedaluwarsa.
  5. Jika valid, ubah password, tandai token sebagai terpakai, lalu invalidasi token reset lain yang masih aktif.
<?php

public function performPasswordReset()
{
    $rules = [
        'token'        => 'required|max_length[256]',
        'new_password' => 'required|min_length[12]|max_length[255]',
    ];

    if (! $this->validate($rules)) {
        return $this->response->setStatusCode(422)->setJSON([
            'status'  => 'error',
            'message' => 'Input tidak valid.',
            'errors'  => $this->validator->getErrors(),
        ]);
    }

    $rawToken = (string) $this->request->getPost('token');
    $tokenHash = hash('sha256', $rawToken);

    $reset = $this->passwordResetModel->findActiveByTokenHash($tokenHash);

    if (! $reset) {
        $this->auditLog('auth.password_reset.token_invalid', [
            'ip' => $this->request->getIPAddress(),
        ]);

        return $this->response->setStatusCode(400)->setJSON([
            'status'  => 'error',
            'message' => 'Token reset tidak valid atau telah kedaluwarsa.',
        ]);
    }

    if (strtotime($reset['expires_at']) < time() || ! empty($reset['used_at'])) {
        return $this->response->setStatusCode(400)->setJSON([
            'status'  => 'error',
            'message' => 'Token reset tidak valid atau telah kedaluwarsa.',
        ]);
    }

    $newPassword = (string) $this->request->getPost('new_password');
    $passwordHash = password_hash($newPassword, PASSWORD_DEFAULT);

    $this->db->transStart();
    $this->userModel->update($reset['user_id'], ['password_hash' => $passwordHash]);
    $this->passwordResetModel->markAsUsed((int) $reset['id']);
    $this->passwordResetModel->invalidateActiveTokensByUserId((int) $reset['user_id']);
    $this->db->transComplete();

    $this->auditLog('auth.password_reset.completed', [
        'user_id' => (int) $reset['user_id'],
        'ip'      => $this->request->getIPAddress(),
    ]);

    return $this->response->setJSON([
        'status'  => 'success',
        'message' => 'Password berhasil diubah.',
    ]);
}

Gunakan transaksi saat mengubah password dan menandai token sebagai terpakai agar tidak terjadi kondisi balapan ketika token dipakai hampir bersamaan.

Skema tabel untuk token reset

Jika menyimpan token di database, struktur minimal biasanya mencakup:

  • id
  • user_id
  • token_hash
  • expires_at
  • used_at
  • requested_ip
  • created_at

Beri index pada user_id, token_hash, dan jika perlu expires_at. Jangan simpan token mentah. Jika email pengiriman bocor dari inbox pengguna, token memang tetap bisa dipakai selama TTL masih aktif, jadi TTL pendek dan single-use tetap penting.

Strategi logging audit yang berguna

Logging audit harus cukup informatif untuk investigasi, tetapi tidak berlebihan sampai menyimpan data sensitif mentah yang tidak perlu.

Event yang layak dicatat:

  • login gagal
  • login diblokir karena rate limit
  • permintaan reset password
  • request reset diblokir karena rate limit
  • token reset invalid
  • token reset sukses dipakai

Field yang umum dicatat:

  • timestamp
  • IP
  • user agent
  • email hash atau user_id jika sudah diketahui
  • nama event
  • reason code, misalnya ip_limit_exceeded atau email_cooldown

Hindari menyimpan password, token mentah, atau data pribadi yang tidak diperlukan. Jika perlu analisis per email, simpan hash email yang dinormalisasi.

Format respons aman untuk endpoint sensitif

Untuk endpoint login dan reset password, gunakan respons yang konsisten dan tidak mengungkap detail internal.

Login gagal

{
  "status": "error",
  "message": "Kredensial tidak valid."
}

Rate limit terlampaui

{
  "status": "error",
  "message": "Terlalu banyak percobaan. Silakan coba lagi beberapa saat."
}

Request reset password

{
  "status": "success",
  "message": "Jika akun terdaftar, instruksi reset password akan dikirim ke email tersebut."
}

Anda boleh menambahkan header Retry-After pada respons 429 jika arsitektur Anda mendukung perhitungan waktu tunggu yang akurat. Ini berguna untuk klien API yang ingin melakukan retry dengan benar.

Validasi input yang sering diabaikan

Proteksi abuse tidak akan efektif jika input terlalu longgar. Beberapa validasi penting:

  • Email: wajib, format valid, panjang masuk akal.
  • Password login: wajib, panjang maksimum dibatasi agar tidak jadi vektor payload besar.
  • Password baru: kebijakan minimum yang jelas, misalnya panjang minimum yang memadai.
  • Token reset: panjang maksimum dibatasi, format diperiksa jika memakai encoding tertentu.

Batasi ukuran body request dan pastikan endpoint sensitif hanya menerima metode HTTP yang benar. Misalnya, login dan reset sebaiknya tidak menerima GET untuk operasi yang mengubah state.

Pengujian skenario brute force dan retry

Jangan menganggap rate limit bekerja hanya karena filter sudah aktif. Uji skenario nyata berikut:

1. Brute force dari satu IP

  1. Kirim request login gagal berulang dari IP yang sama.
  2. Pastikan setelah ambang terlampaui, respons menjadi 429.
  3. Pastikan request normal kembali diterima setelah TTL habis.

2. Satu email diserang dari beberapa IP

  1. Simulasikan beberapa sumber request ke email yang sama.
  2. Pastikan throttle/cooldown per email tetap aktif walau IP berbeda.

3. Reset password spam

  1. Kirim banyak request reset untuk email yang sama.
  2. Pastikan hanya token terbaru yang aktif.
  3. Pastikan inbox tidak dibanjiri jika Anda menambahkan antrian atau guard tambahan.

4. Token replay

  1. Gunakan token reset yang valid sekali.
  2. Coba pakai ulang token yang sama.
  3. Pastikan selalu gagal.

5. Token kedaluwarsa

  1. Buat token dengan TTL pendek pada environment test.
  2. Tunggu hingga habis.
  3. Pastikan respons invalid/expired konsisten.

6. Retry klien setelah 429

  1. Pastikan klien atau frontend tidak melakukan retry agresif otomatis saat menerima 429.
  2. Jika ada Retry-After, uji apakah klien mematuhinya.

Jika Anda menulis pengujian integrasi, fokus pada transisi status: sebelum limit, saat limit tercapai, dan setelah jendela waktu berakhir.

Kesalahan umum yang perlu dihindari

  • Hanya membatasi per IP: tidak cukup untuk serangan terdistribusi.
  • Pesan error berbeda untuk email valid dan tidak valid: memicu user enumeration.
  • Menyimpan token reset dalam plaintext: berisiko jika database bocor.
  • Tidak menginvalidasi token lama: beberapa token aktif memperbesar permukaan serangan.
  • TTL token terlalu panjang: memperbesar peluang token dicuri dan dipakai.
  • Lockout terlalu agresif: user sah bisa terkunci akibat serangan ke akunnya.
  • Counter tersimpan lokal per server: tidak konsisten pada deployment multi-instance.
  • Tidak memakai transaksi saat reset password: rawan race condition.

Rekomendasi kebijakan praktis

Tidak ada angka universal yang cocok untuk semua aplikasi, tetapi pola kebijakan berikut sering masuk akal sebagai titik awal:

  • Throttle login per IP untuk jendela pendek.
  • Cooldown per email setelah beberapa login gagal beruntun.
  • Throttle reset password per IP dan per email.
  • TTL token reset singkat dan single-use.
  • Invalidasi semua token reset aktif saat token baru dibuat atau password berhasil diubah.
  • Audit log untuk semua event autentikasi penting.

Sesuaikan angka limit dengan karakter aplikasi, volume trafik normal, dan toleransi false positive. Aplikasi internal dengan user sedikit bisa memakai limit lebih ketat. Aplikasi publik dengan banyak user di jaringan bersama perlu lebih hati-hati agar tidak terlalu sering memblokir user sah dari IP yang sama.

Penutup

Membangun CodeIgniter 4 rate limit login dan reset password yang benar berarti menggabungkan kontrol teknis dan keputusan desain yang mengurangi kebocoran informasi. Filter CI4 bisa menahan laju request, tetapi keamanan sebenarnya datang dari kombinasi throttle per IP, pembatasan per email, cooldown yang masuk akal, respons generik, token reset yang di-hash dan punya TTL, serta logging audit yang baik.

Jika Anda ingin hasil yang stabil di production, prioritaskan penyimpanan counter yang konsisten antar instance, uji skenario brute force dan replay, lalu tinjau ulang limit berdasarkan data audit nyata. Endpoint autentikasi sebaiknya selalu dianggap area dengan observabilitas dan pembatasan paling ketat.