Rate limiting login API di Express.js adalah lapisan pertahanan dasar untuk memperlambat brute force dan credential stuffing, terutama ketika endpoint login dapat diakses publik. Pendekatan yang efektif bukan sekadar membatasi jumlah request per IP, tetapi menggabungkan limit per IP, per akun, dan sinyal tambahan agar serangan tidak mudah menghindari aturan dengan rotasi IP atau menargetkan banyak akun.

Di artikel ini, kita akan membangun panduan praktis untuk hardening endpoint login di Express.js dengan Redis sebagai penyimpanan counter. Kita juga akan membahas trade-off algoritma rate limit, cara aman merespons kegagalan login tanpa membocorkan status akun, penanganan aplikasi di balik proxy/load balancer, serta logging dan metrik yang layak dipantau di produksi.

Kapan rate limiting login dibutuhkan

Endpoint login adalah target umum karena penyerang bisa mencoba banyak kombinasi password, mendaur ulang kredensial bocor dari layanan lain, atau mengganggu layanan dengan request dalam jumlah besar. Tanpa pembatasan, satu endpoint login bisa menjadi titik masuk untuk:

  • Brute force: mencoba banyak password untuk satu akun.
  • Credential stuffing: mencoba kredensial bocor pada banyak akun.
  • Abuse volumetrik: membanjiri endpoint untuk menghabiskan resource aplikasi atau database.

Rate limiting tidak menghentikan semua serangan otomatis, tetapi menaikkan biaya serangan dan memperlambat percobaan secara signifikan. Ini membuat lapisan lain seperti MFA, deteksi anomali, CAPTCHA adaptif, dan notifikasi keamanan menjadi lebih efektif.

Desain rate limit untuk endpoint login

1. Limit per IP

Limit per IP membatasi jumlah percobaan dari satu alamat IP dalam periode tertentu. Ini berguna untuk menahan penyerang yang mengandalkan satu atau sedikit IP.

Kelebihan:

  • Mudah dipahami dan relatif murah diimplementasikan.
  • Efektif untuk serangan sederhana dari satu sumber.
  • Cocok sebagai lapisan awal untuk melindungi infrastruktur.

Kekurangan:

  • Mudah dielakkan dengan rotasi IP, botnet, atau proxy.
  • Bisa menimbulkan false positive pada pengguna di jaringan NAT, kantor, kampus, atau operator seluler.

2. Limit per akun

Limit per akun membatasi percobaan login terhadap satu identifier akun, misalnya email atau username yang sudah dinormalisasi. Ini penting untuk menahan brute force pada satu korban walaupun penyerang berganti-ganti IP.

Kelebihan:

  • Efektif terhadap serangan yang menargetkan satu akun.
  • Tidak mudah dihindari hanya dengan rotasi IP.

Kekurangan:

  • Jika implementasinya salah, respons atau perilakunya bisa membocorkan apakah akun ada atau tidak.
  • Bisa disalahgunakan untuk mengunci akun korban jika aturan lock terlalu agresif.

3. Kombinasi per IP dan per akun

Untuk endpoint login, kombinasi keduanya biasanya lebih masuk akal daripada memilih salah satu. Contohnya:

  • Per IP untuk menahan lonjakan request dari satu sumber.
  • Per akun untuk menahan percobaan berulang ke identitas yang sama.
  • Per pasangan akun+IP untuk memberi granularitas tambahan dan mengurangi dampak pada pengguna sah dari jaringan bersama.

Dengan kombinasi ini, Anda bisa memperlambat penyerang tanpa terlalu cepat memblokir seluruh jaringan atau mengunci akun secara kasar.

Memilih algoritma rate limiting

Fixed window

Fixed window menghitung request dalam interval waktu tetap, misalnya 10 percobaan per 10 menit.

Kelebihan:

  • Paling sederhana diimplementasikan.
  • Mudah disimpan di Redis dengan counter dan TTL.

Kekurangan:

  • Ada efek batas jendela: penyerang bisa mengirim request di akhir satu jendela dan awal jendela berikutnya, sehingga lolos lebih banyak dalam waktu singkat.

Sliding window

Sliding window menghitung request berdasarkan rentang waktu bergerak. Akurasi lebih baik dibanding fixed window karena tidak terlalu terpengaruh efek batas interval.

Kelebihan:

  • Lebih adil dan lebih presisi.
  • Lebih cocok untuk endpoint sensitif seperti login.

Kekurangan:

  • Implementasi lebih kompleks.
  • Jika menggunakan penyimpanan timestamp per request, biaya memori dan operasi lebih tinggi.

Token bucket

Token bucket memberi token yang terisi ulang secara bertahap. Request hanya boleh lewat jika token tersedia. Model ini bagus untuk mengizinkan burst kecil tapi tetap mengendalikan laju rata-rata.

Kelebihan:

  • Fleksibel untuk menoleransi burst pendek.
  • Cocok bila Anda ingin menjaga pengalaman pengguna tetap baik saat beberapa request valid datang berdekatan.

Kekurangan:

  • Implementasi dan debugging lebih rumit daripada fixed window.
  • Untuk login, toleransi burst perlu diatur hati-hati agar tidak terlalu longgar.

Pilih yang mana untuk login?

Jika kebutuhan Anda adalah implementasi cepat dan stabil, fixed window di Redis sudah memadai sebagai baseline. Jika aplikasi Anda berisiko tinggi atau sering menghadapi abuse terdistribusi, pertimbangkan sliding window atau kombinasi fixed window dengan backoff progresif. Token bucket berguna bila Anda ingin kontrol yang lebih halus, tetapi untuk endpoint login, kesederhanaan dan prediktabilitas sering lebih penting.

Arsitektur praktis: Express.js + Redis

Redis cocok untuk rate limiting karena operasi counter cepat, mendukung TTL, dan bisa dipakai bersama oleh banyak instance aplikasi. Jangan menyimpan counter hanya di memori proses Node.js jika aplikasi Anda berjalan di beberapa instance, container, atau pod, karena limit akan tidak konsisten.

Struktur key Redis yang aman dan terorganisasi

Gunakan namespace yang jelas dan hindari menyimpan data sensitif mentah bila tidak perlu. Untuk identifier akun, lebih aman menyimpan hash dari identifier yang sudah dinormalisasi daripada email mentah.

Contoh struktur key:

  • rl:login:ip:{ip}
  • rl:login:acct:{accountHash}
  • rl:login:pair:{accountHash}:{ip}
  • rl:login:lock:{accountHash}
  • rl:login:backoff:{accountHash}

Normalisasi akun penting agar User@example.com dan user@example.com diperlakukan konsisten bila sistem Anda memang case-insensitive. Jangan menebak aturan normalisasi jika sistem identitas Anda punya kebijakan khusus.

Implementasi middleware rate limiting login di Express.js

Berikut contoh implementasi praktis dengan asumsi Anda memakai client Redis yang menyediakan operasi dasar seperti incr, expire, ttl, dan get. Kode ini fokus pada pola, bukan pada satu library tertentu.

const crypto = require('crypto');

function sha256(value) {
  return crypto.createHash('sha256').update(value).digest('hex');
}

function normalizeAccount(raw) {
  if (!raw || typeof raw !== 'string') return '';
  return raw.trim().toLowerCase();
}

function getClientIp(req) {
  return req.ip;
}

async function incrementFixedWindow(redis, key, windowSec) {
  const count = await redis.incr(key);
  if (count === 1) {
    await redis.expire(key, windowSec);
  }
  const ttl = await redis.ttl(key);
  return { count, ttl };
}

function loginRateLimit({ redis, ipLimit, accountLimit, pairLimit }) {
  return async function (req, res, next) {
    try {
      const ip = getClientIp(req);
      const account = normalizeAccount(req.body?.email || req.body?.username || '');
      const accountHash = account ? sha256(account) : 'unknown';

      const ipKey = `rl:login:ip:${ip}`;
      const acctKey = `rl:login:acct:${accountHash}`;
      const pairKey = `rl:login:pair:${accountHash}:${ip}`;
      const lockKey = `rl:login:lock:${accountHash}`;

      const locked = await redis.get(lockKey);
      if (locked) {
        return res.status(429).json({
          error: 'Terlalu banyak percobaan login. Coba lagi nanti.'
        });
      }

      const [ipState, acctState, pairState] = await Promise.all([
        incrementFixedWindow(redis, ipKey, ipLimit.windowSec),
        incrementFixedWindow(redis, acctKey, accountLimit.windowSec),
        incrementFixedWindow(redis, pairKey, pairLimit.windowSec)
      ]);

      const blocked =
        ipState.count > ipLimit.max ||
        acctState.count > accountLimit.max ||
        pairState.count > pairLimit.max;

      if (blocked) {
        const retryAfter = Math.max(ipState.ttl, acctState.ttl, pairState.ttl, 1);
        res.set('Retry-After', String(retryAfter));
        return res.status(429).json({
          error: 'Terlalu banyak percobaan login. Coba lagi nanti.'
        });
      }

      req.rateLimitContext = { accountHash, ip };
      next();
    } catch (err) {
      next(err);
    }
  };
}

module.exports = { loginRateLimit, normalizeAccount, sha256 }; 

Contoh pemakaian di route login:

const express = require('express');
const { loginRateLimit } = require('./loginRateLimit');

const app = express();
app.use(express.json());

app.set('trust proxy', 1);

app.post('/api/login',
  loginRateLimit({
    redis,
    ipLimit: { max: 20, windowSec: 60 },
    accountLimit: { max: 8, windowSec: 900 },
    pairLimit: { max: 5, windowSec: 300 }
  }),
  async (req, res) => {
    const { email, password } = req.body;

    const user = await findUserByEmail(email);
    const valid = user ? await verifyPassword(password, user.passwordHash) : false;

    if (!valid) {
      return res.status(401).json({
        error: 'Email atau password tidak valid.'
      });
    }

    return res.status(200).json({ token: await issueToken(user) });
  }
);

Angka limit di atas hanya contoh. Nilai aktual harus disesuaikan dengan pola trafik, risiko, dan pengalaman pengguna yang diinginkan.

Respons HTTP yang aman dan tidak membocorkan status akun

Salah satu kesalahan umum adalah memberi respons berbeda antara akun yang tidak ada, password salah, akun terkunci, atau akun belum aktif. Perbedaan ini bisa dipakai penyerang untuk enumerasi akun.

Prinsip yang sebaiknya diikuti:

  • Gunakan pesan generik untuk kegagalan autentikasi, misalnya Email atau password tidak valid.
  • Untuk rate limit, gunakan pesan yang juga generik, misalnya Terlalu banyak percobaan login. Coba lagi nanti.
  • Hindari menyebut apakah akun ada, terkunci, atau sedang dibatasi pada respons publik.
  • Bila perlu, gunakan Retry-After pada 429. Namun tetap jangan sertakan detail yang mengungkap keberadaan akun tertentu.

Untuk sebagian sistem, 401 dipakai untuk kredensial salah dan 429 untuk limit terlampaui. Itu lazim, selama isi pesannya tidak membedakan status akun secara sensitif. Jika Anda sangat ketat terhadap anti-enumerasi, pastikan timing dan bentuk respons tidak terlalu mudah dibedakan antar skenario.

Backoff, lock sementara, dan reset yang tidak mudah disalahgunakan

Backoff progresif

Dibanding langsung memblokir keras setelah beberapa kali gagal, Anda bisa menerapkan backoff progresif. Misalnya, setiap kegagalan berturut-turut menambah jeda kecil sebelum percobaan berikutnya diizinkan.

Kelebihan:

  • Lebih ramah untuk pengguna sah yang salah mengetik beberapa kali.
  • Memperlambat brute force tanpa lock yang terlalu agresif.

Kekurangan:

  • Penyerang masih bisa mencoba, hanya lebih lambat.
  • Perlu state tambahan per akun atau per pasangan akun+IP.

Lock sementara

Temporary lock bisa diterapkan setelah ambang tertentu tercapai, tetapi jangan terlalu mudah dipicu hanya dari satu sinyal. Jika lock murni berdasarkan identifier akun, penyerang bisa mencoba mengunci akun korban dari banyak IP.

Pola yang lebih aman:

  • Gunakan lock singkat, bukan permanen.
  • Gabungkan sinyal per akun dan per pasangan akun+IP.
  • Naikkan durasi lock bertahap jika pola gagal berlanjut.
  • Pastikan pengguna sah masih punya jalur pemulihan yang aman.

Reset counter

Jangan sembarang mereset semua counter hanya karena ada satu login sukses dari IP tertentu. Jika reset terlalu mudah, penyerang bisa memanfaatkannya untuk menjaga counter tetap rendah.

Prinsip reset yang lebih aman:

  • Reset atau kurangi state kegagalan setelah login sukses untuk akun yang sama.
  • Jangan reset seluruh limit per IP global.
  • Pertimbangkan penurunan bertahap, bukan pembersihan total, untuk sinyal abuse yang lebih luas.

Contoh sederhana setelah login sukses:

async function onSuccessfulLogin(redis, accountHash, ip) {
  const keys = [
    `rl:login:acct:${accountHash}`,
    `rl:login:pair:${accountHash}:${ip}`,
    `rl:login:backoff:${accountHash}`,
    `rl:login:lock:${accountHash}`
  ];

  await redis.del(keys);
}

Hati-hati: penghapusan total state bukan selalu pilihan terbaik. Untuk sebagian sistem, lebih aman membiarkan limit per IP tetap hidup sampai TTL habis.

Redis: atomicity, TTL, dan konsistensi

Contoh INCR lalu EXPIRE cukup umum, tetapi ada celah kecil jika proses gagal di antara dua operasi tersebut. Untuk implementasi yang lebih kuat, gunakan transaksi atau script server-side agar increment dan pengaturan TTL berjalan atomik.

Hal yang perlu diperhatikan:

  • TTL harus hanya diset saat counter baru dibuat, agar jendela tidak terus memanjang pada setiap request bila Anda memang memilih fixed window.
  • Clock source sebaiknya konsisten. Jika memakai algoritma yang bergantung timestamp, gunakan waktu dari sumber yang seragam atau logika di Redis/script.
  • Redis outage perlu strategi. Untuk endpoint login, banyak tim memilih fail closed sebagian atau mode degradasi yang tetap defensif, bukan membiarkan semua percobaan lewat tanpa kontrol.

Jika Anda menjalankan banyak instance Express, Redis menjadi sumber kebenaran bersama. Tanpa ini, penyerang cukup menyebar request ke beberapa instance untuk menghindari limit per proses.

Handling di balik proxy dan load balancer

Di deployment nyata, Express sering berada di belakang reverse proxy atau load balancer. Jika konfigurasi trust proxy salah, req.ip bisa mengarah ke IP proxy, bukan IP klien. Akibatnya, semua pengguna terlihat datang dari alamat yang sama dan rate limit menjadi tidak akurat.

Hal yang perlu dilakukan:

  • Konfigurasikan app.set('trust proxy', ...) sesuai topologi deployment Anda.
  • Pastikan hanya proxy tepercaya yang boleh menyuntik header forwarding.
  • Jangan langsung mempercayai X-Forwarded-For jika aplikasi bisa diakses tanpa melewati proxy tepercaya.

Contoh:

app.set('trust proxy', 1);

Nilai di atas sering dipakai bila ada satu hop proxy di depan aplikasi. Namun nilai yang benar bergantung pada arsitektur Anda. Salah konfigurasi di sini adalah sumber bug yang sangat umum pada rate limiting login API di Express.js.

Edge case yang sering terlewat

1. NAT dan jaringan bersama

Banyak pengguna sah bisa berbagi satu IP publik. Jika limit per IP terlalu ketat, satu kantor atau kampus bisa terkena 429 bersama-sama. Karena itu, jangan hanya mengandalkan limit per IP.

2. IPv6

Penanganan IPv6 bisa menimbulkan ledakan kardinalitas key jika tidak dinormalisasi dengan bijak. Pada sebagian sistem, agregasi prefix dipakai untuk mitigasi abuse, tetapi ini harus dipertimbangkan hati-hati agar tidak menambah false positive.

3. Identifier akun kosong atau tidak valid

Request tanpa email/username tetap harus dibatasi. Jika tidak, penyerang bisa menghindari limit per akun dengan mengirim payload rusak. Karena itu, limit per IP tetap penting untuk request malformed.

4. Akun yang belum ada

Jika Anda hanya membuat counter per akun untuk user yang ditemukan di database, Anda membuka celah enumerasi. Counter per akun sebaiknya didasarkan pada identifier yang dikirim, bukan pada hasil lookup akun semata.

5. Sukses login dari IP berbeda

Jika pengguna sah berhasil login dari perangkat baru sementara ada serangan berjalan dari IP lain, reset state harus selektif. Jangan sampai keberhasilan satu sesi menghapus semua sinyal abuse global untuk akun tersebut tanpa pertimbangan.

Logging, metrik, dan alert dasar

Rate limit tanpa observabilitas sulit dievaluasi. Anda perlu tahu apakah aturan terlalu longgar, terlalu ketat, atau memicu banyak false positive.

Apa yang perlu dicatat

  • Timestamp, route, metode, dan hasil HTTP.
  • IP klien yang sudah diproses sesuai trust proxy.
  • Hash akun atau identifier yang sudah dipseudonimkan, bukan email mentah jika tidak perlu.
  • Alasan blokir: IP, akun, pasangan akun+IP, atau lock sementara.
  • TTL atau durasi tunggu tersisa.

Metrik yang berguna

  • Jumlah 401 pada endpoint login.
  • Jumlah 429 pada endpoint login.
  • Distribusi blokir berdasarkan rule: IP, akun, pasangan akun+IP.
  • Rasio login sukses vs gagal.
  • Top IP atau top account hash yang paling sering dibatasi.

Alert minimal

  • Lonjakan tajam 429 dalam periode singkat.
  • Lonjakan login gagal dari banyak IP ke sedikit akun.
  • Lonjakan login gagal dari sedikit IP ke banyak akun.
  • Redis tidak tersedia atau latensi Redis meningkat pada endpoint login.

Alert sebaiknya menandai pola, bukan hanya satu event tunggal. Ini membantu membedakan serangan nyata dari pengguna yang sekadar salah mengetik password.

Pola middleware yang lebih lengkap: memisahkan pre-check dan post-result

Dalam praktik, middleware rate limit login sering lebih rapi jika dibagi dua tahap:

  1. Pre-check: menolak request jika state saat ini sudah melebihi ambang.
  2. Post-result update: setelah hasil autentikasi diketahui, tambahkan counter gagal atau reset sebagian state untuk login sukses.

Pendekatan ini lebih akurat daripada selalu menambah counter sebelum verifikasi, karena Anda bisa membedakan request yang gagal autentikasi dari request yang sukses. Namun pre-check tetap dibutuhkan agar request yang sudah jelas terblokir bisa dihentikan lebih awal sebelum membebani database atau verifikasi password.

async function recordFailedLogin(redis, accountHash, ip) {
  await Promise.all([
    incrementFixedWindow(redis, `rl:login:acct:${accountHash}`, 900),
    incrementFixedWindow(redis, `rl:login:pair:${accountHash}:${ip}`, 300),
    incrementFixedWindow(redis, `rl:login:ip:${ip}`, 60)
  ]);
}

async function maybeApplyTemporaryLock(redis, accountHash, failures) {
  if (failures >= 10) {
    await redis.set(`rl:login:lock:${accountHash}`, '1', { EX: 900 });
  }
}

Detail implementasinya bergantung pada flow login Anda, tetapi prinsip pemisahan tahap ini biasanya memudahkan reasoning dan auditing.

Kesalahan umum saat menerapkan rate limiting login API di Express.js

  • Hanya membatasi per IP, sehingga credential stuffing dari banyak IP tetap mudah lolos.
  • Menyimpan counter di memori proses pada deployment multi-instance.
  • Respons error terlalu spesifik dan mempermudah enumerasi akun.
  • Salah konfigurasi trust proxy, sehingga semua user terlihat memiliki IP yang sama.
  • Lock akun terlalu agresif dan mudah dipicu penyerang terhadap korban.
  • Tidak memantau 429 dan 401, sehingga tidak tahu apakah aturan bekerja atau justru mengganggu user sah.
  • Mereset state secara berlebihan setelah satu login sukses.

Checklist implementasi produksi

  • Gunakan Redis sebagai penyimpanan counter terpusat.
  • Terapkan kombinasi limit per IP, per akun, dan per pasangan akun+IP.
  • Normalisasi identifier akun secara konsisten.
  • Pseudonimkan identifier sensitif saat disimpan di Redis atau log.
  • Konfigurasikan trust proxy sesuai topologi nyata.
  • Gunakan respons generik untuk login gagal dan rate limit.
  • Tambahkan Retry-After untuk 429 bila relevan.
  • Pertimbangkan backoff progresif sebelum lock yang lebih keras.
  • Pastikan lock sementara tidak mudah dipakai untuk denial-of-service pada akun korban.
  • Uji edge case: NAT, request malformed, akun tidak ada, dan banyak instance aplikasi.
  • Tambahkan logging terstruktur, metrik 401/429, dan alert sederhana.
  • Siapkan strategi degradasi saat Redis bermasalah.
  • Gabungkan dengan kontrol lain seperti MFA, deteksi anomali, dan notifikasi keamanan untuk akun sensitif.

Penutup

Rate limiting login API di Express.js yang efektif bukan soal menambahkan satu middleware lalu selesai. Endpoint login perlu desain yang mempertimbangkan perilaku penyerang: rotasi IP, credential stuffing, enumerasi akun, dan penyalahgunaan lock. Kombinasi limit per IP dan per akun, penyimpanan state di Redis, respons yang tidak bocor, serta observabilitas yang memadai adalah fondasi yang realistis dan bisa diterapkan di produksi.

Mulailah dari desain yang sederhana namun benar: fixed window di Redis, limit gabungan, dan logging yang baik. Setelah itu, evaluasi data nyata dari sistem Anda untuk memutuskan apakah perlu sliding window, backoff lebih halus, atau aturan adaptif tambahan.