Rate Limiting

How the IAM service uses layered rate limiters with union pairing, strike-based blocking, consecutive failure caches, and per-endpoint limiter groups to protect every sensitive route.

The IAM service uses rate-limiter-flexible for all rate limiting. Every sensitive endpoint has its own named limiter group built at startup from the rate_limiters section of the service configuration. The built-in limiters store state in MySQL, via a dedicated callback-based pool separate from the main auth pool, and maintain in-memory mirrors for fast lookups.

The system layers multiple limiters per endpoint. A typical endpoint has a union limiter combining a burst and slow limiter into a single gate, an IP limiter, and an identity limiter (keyed on email, OAuth subject, token hash, or user ID). All layers must pass for a request to proceed. If any layer rejects, the service returns 429 Too Many Requests with a Retry-After header.

On top of the limiter layer, a strike system tracks consecutive failures per key in an LRU cache. When a key accumulates enough strikes (the maxBans threshold), the guard function permanently blocks the key via the limiter's block() method and adds it to a global block cache. Blocked keys are rejected immediately on subsequent requests without consuming limiter points.


Overview

Dual-Layer Design

Each rate limiter in the system operates on two storage layers simultaneously:

LayerStoragePurpose
PrimaryMySQLPersistent state. Survives process restarts. Shared across multiple IAM instances behind a load balancer.
In-memoryNode.js process memoryFast path. Blocks requests locally when inMemoryBlockOnConsumed is exceeded, without hitting the database.

The in-memory layer acts as an insurance mechanism. If the MySQL connection fails, the in-memory limiter continues to enforce rate limits. If both layers are healthy, the in-memory layer short-circuits obvious violations before they reach the database.

Storage Pool

The rate limiter pool is separate from the main authentication pool. It uses the callback-based mysql API because rate-limiter-flexible requires a callback-style connection. Configure it in the store.rate_limiters_pool section:

store.rate_limiters_pool.store
mysql.PoolOptions required
MySQL pool options for the rate limiter connection. Same format as the main pool but typically points to a separate database.
store.rate_limiters_pool.dbName
string required
Database name for rate limiter tables. The service uses this value to create the database if it does not exist.

See Database for more details.


Core Utilities

The rate limiting system is built from four composable functions. When using the IAM service as a library, all four are exported from @riavzon/auth.

makeRateLimiter

Creates a single rate limiter instance. The sql parameter controls whether the limiter persists state in MySQL or runs purely in memory. The BlackWhiteList parameter wraps the limiter with an allow/deny list layer.

import { makeRateLimiter } from '@riavzon/auth'

const limiter = makeRateLimiter(true, false, {
  dbName: 'rate_limiters',
  storeClient: pool,
  storeType: 'mysql2',
  tableName: 'login',
  keyPrefix: 'login_burst',
  points: 1,
  duration: 1,
  blockDuration: 1800,
  inMemoryBlockOnConsumed: 2,
  inMemoryBlockDuration: 1800,
})
sql
boolean required
When true, creates a RateLimiterMySQL backed by the provided MySQL pool with an in-memory RateLimiterMemory as the insurance fallback. When false, creates a pure RateLimiterMemory.
BlackWhiteList
boolean required
When true, wraps the limiter in RLWrapperBlackAndWhite. Whitelisted keys bypass rate limiting entirely. The default whitelist check matches 10.10.10.10 (a test IP).
settings
object required
Limiter configuration. Contains both the core rate-limiter-flexible fields and MySQL-specific fields which is required when sql is true.

Settings Fields

FieldTypeRequiredDescription
keyPrefixstringYesUnique prefix for this limiter in the store. Used as the MySQL table column key and the in-memory map key.
pointsnumberYesMaximum number of requests allowed within the duration window.
durationnumberYesTime window in seconds. Points reset after this period.
blockDurationnumberYesHow long, in seconds, to block the key after all points are consumed. 0 means permanent block.
inMemoryBlockOnConsumednumberNoWhen consumed points reach this threshold, the in-memory layer blocks the key without checking MySQL. Typically set slightly above points to catch bursts.
inMemoryBlockDurationnumberNoHow long, in seconds, the in-memory block lasts. Usually matches blockDuration.
dbNamestringWhen sql: trueThe MySQL database name for this limiter's storage.
storeClientPoolWhen sql: trueThe callback-based MySQL pool from poolForLibrary().
storeTypestringWhen sql: trueAlways 'mysql2'. Identifies the pool type for rate-limiter-flexible.
tableNamestringWhen sql: trueThe MySQL table name where this limiter stores its counters. Multiple limiters can share a table if they have different keyPrefix values.

unionLimiter

Combines two or more individual limiters into a single gate. Both limiters in the union are consumed on every attempt. If either limiter rejects, the request is blocked.

import { makeRateLimiter, unionLimiter } from '@riavzon/auth'

const burst = makeRateLimiter(true, false, {
  keyPrefix: 'login_burst',
  points: 1,
  duration: 1,
  blockDuration: 1800,
  // ... other fields
})

const slow = makeRateLimiter(true, false, {
  keyPrefix: 'login_slow',
  points: 5,
  duration: 3600,
  blockDuration: 1800,
  // ... other fields
})

const loginUnion = unionLimiter([burst, slow], false)
limiters
Array<RateLimiterMemory | RateLimiterMySQL> required
The individual limiters to combine. Typically a burst limiter (low points, short duration) and a slow limiter (higher points, longer duration).
blackWhiteList
boolean required
When true, wraps the union in RLWrapperBlackAndWhite. The block() and delete() methods from the underlying union are forwarded to the wrapper so the guard function can still block and reset keys.

The returned object extends RateLimiterUnion with two additional methods:

MethodDescription
block(key, durationSec)Blocks the key on all underlying limiters simultaneously. durationSec = 0 means permanent.
delete(key)Deletes the key from all underlying limiters, resetting their state.
The burst + slow pairing is the foundation of every built-in limiter group. The burst limiter catches automated flooding (e.g., 1 request per second). The slow limiter catches distributed or low-rate credential stuffing (e.g., 5 attempts per hour). A legitimate user who types slowly will never hit either limit. An attacker who spreads attempts over time will hit the slow limiter even if they avoid the burst threshold.

guard

The primary entry point for applying rate limiting in a controller. It combines limiter consumption, consecutive failure tracking, and permanent blocking into a single call. Returns true if the request is allowed, false if rate limited.

import { guard, makeConsecutiveCache } from '@riavzon/auth'

const consecutiveCache = makeConsecutiveCache<{ countData: number }>(500, 60_000)

const allowed = await guard(
  limiter,                // Any limiter: Memory, MySQL, or BlockableUnion
  req.ip!,                // The key to rate limit on
  consecutiveCache,       // LRU cache tracking consecutive failures
  2,                      // maxBans: strikes before permanent block
  'ip',                   // Label for log messages
  log,                    // Pino logger
  res,                    // Express response (guard sends 429 automatically)
  3600                    // Optional: block duration in seconds (0 = permanent)
)

if (!allowed) return      // Response already sent
limiter
Limiter required
The rate limiter to consume against. Can be a RateLimiterMemory, RateLimiterMySQL, or a BlockableUnion from unionLimiter().
key
string required
The identifier to rate limit on. Typically an IP address, email, token hash, or composite key like ${ip}_${email}. Keys longer than 255 characters are automatically SHA-256 hashed before use.
cache
LRUCache<string, { countData: number }> required
An LRU cache that tracks how many consecutive times this key has been rate limited. Each guard() call that fails increments the counter. Successful calls do not clear the counter (that is the controller's responsibility).
maxBans
number required
The number of consecutive failures required before the key is permanently blocked. Lower values are stricter. The built-in limiters use 1 for high-confidence keys (token hashes), 2 for identity keys (IPs, emails), and 3 for composite keys.
label
string required
Descriptive string included in log entries. Used to identify which limiter triggered in the logs (e.g., 'ip', 'email', 'compositeKey').
log
pino.Logger required
Pino logger instance. The guard logs warnings on strikes and blocks, and info messages on successful passes (including remaining points and next allowed time).
res
Response required
Express response object. When the guard blocks a request, it sends the 429 response directly, including the Retry-After header and a JSON error body.
seconds
number
Block duration in seconds when maxBans is exceeded. Defaults to 0 (permanent block for the duration of the block cache TTL, which is 7 days). Set to a positive value for time-limited blocks.

How the Guard Works

Check the global block cache

The guard maintains a process-wide LRU cache (isBlockedCache) with a 7-day TTL and a maximum of 1,000 entries. If the key is already in this cache with Blocked: true, the guard sends 429 immediately and returns false. No limiter points are consumed.

Consume limiter points

Calls consumeOrReject() to attempt consuming one point from the limiter. If the limiter rejects (all points exhausted), consumeOrReject sends the 429 response with a Retry-After header and returns null.

Record a strike

If consumption failed, the guard increments the key's strike counter in the consecutive cache. The counter starts at 0 and increments by 1 on each failure.

Check for permanent block

If the strike count reaches or exceeds maxBans, the guard calls limiter.block(key, seconds) to permanently block the key at the limiter level, then adds the key to isBlockedCache. Future requests with this key are rejected at step 1 without consuming points.

Allow the request

If consumption succeeded, the guard deletes the key from isBlockedCache (in case it was previously blocked and the block expired) and returns true. The controller can proceed.

The isBlockedCache is process-local. If you run multiple IAM instances, a key blocked on one instance is not visible to other instances. The MySQL-backed limiter block (from limiter.block()) is shared across instances, but the fast-path cache check only works within the process that triggered it.

consumeOrReject

Low-level function that attempts to consume one point from a limiter. If consumption fails, it sends the 429 response and returns null. If consumption succeeds, it returns the RateLimiterRes object with remaining points and timing data.

import { consumeOrReject } from '@riavzon/auth'

const result = await consumeOrReject(limiter, key, res, log)

if (result === null) {
  // Request already rejected with 429
  return
}

// result.remainingPoints — how many attempts the key has left
// result.consumedPoints  — how many attempts the key has used
// result.msBeforeNext    — milliseconds until the window resets

When the limiter rejects:

ResponseValue
Status429 Too Many Requests
HeaderRetry-After: {seconds} (computed as Math.ceil(msBeforeNext / 1000), minimum 1)
Body{ error: 'Too many requests', retry: {seconds} }
Most controllers use guard() instead of calling consumeOrReject directly. Use consumeOrReject when you need custom logic between the consumption check and the response, or when you do not want the strike/block escalation that guard provides.

resetLimiters

Resets all accumulated points for a key across an array of limiters. Typically called after a successful authentication to clear penalties that accumulated during failed attempts.

import { resetLimiters } from '@riavzon/auth'

resetLimiters(log, compositeKey, [burst, slow, ipLimiter, emailLimiter])
log
pino.Logger required
Logger for tracking which limiters were reset.
key
string required
The key to reset across all limiters.
limiters
Array<RateLimiterMemory | RateLimiterMySQL | RLWrapperBlackAndWhite> required
The limiter instances to clear. The function calls .delete(key) on each one.
The built-in limiter groups export a convenience function (typically named resetLimitersUni) that resets all limiters in the group at once. Controllers call this on successful authentication rather than tracking individual limiter references.

makeConsecutiveCache

Factory function that creates an LRU cache for tracking consecutive failures. Each cache instance is scoped to a specific key type (IP, email, composite, etc.) and has its own TTL.

import { makeConsecutiveCache } from '@riavzon/auth'

const cache = makeConsecutiveCache<{ countData: number }>(
  500,       // Maximum entries the cache can hold
  60_000     // TTL per entry in milliseconds
)
max
number required
Maximum number of entries. When the cache is full, the least recently used entry is evicted.
ttl
number required
Time-to-live per entry in milliseconds. Entries older than this value are automatically evicted. This controls how long a key's strike history persists.

The consecutive cache is separate from the limiter store. Limiter points reset on their own schedule (based on duration). The consecutive cache tracks how many times a key has been caught by the limiter, regardless of when the limiter's window resets.


Key Strategies

Rate limiters are identified by the key they track. The IAM service uses four key strategies across its endpoint groups.

IP Key

The simplest strategy. Uses req.ip as the limiter key. Protects against a single source flooding an endpoint.

await guard(ipLimiter, req.ip!, consecutiveForIp, 2, 'ip', log, res)

Identity Key

Uses the user's identity (email, OAuth subject, user ID, or token hash) as the key. Protects against attacks targeting a specific account from rotating IPs.

await guard(emailLimiter, email, consecutiveForEmail, 2, 'email', log, res)

Composite Key

Combines the IP and identity into a single key with an underscore separator. Protects against a single source targeting a specific account. More precise than either key alone.

const compositeKey = `${req.ip!}_${email}`
await guard(uniLimiter, compositeKey, consecutive429, 3, 'ip+email', log, res)

Global Key

Uses a fixed string as the key. Protects against system-wide abuse by capping total requests across all users. The email MFA and password reset flows use 'global_emails' to cap the total number of outbound emails the system sends per day.

await guard(globalEmailLimiter, 'global_emails', consecutiveForGlobal, 1, 'globalEmailLimiter', log, res)
Keys longer than 255 characters are automatically hashed with SHA-256 before being used as a limiter key. This handles composite keys and token hashes that could exceed MySQL column length limits.

Guard Patterns in Controllers

Every endpoint that uses rate limiting follows the same pattern: call guard() one or more times at the top of the controller, each with a different limiter and key. If any guard rejects, the controller returns immediately (the 429 response was already sent by the guard).

Login

The login controller applies three guard layers before attempting authentication:

IP guard

Limits the total number of login attempts from a single IP. Uses the IP union limiter. maxBans: 2.

Email guard

Limits the number of login attempts targeting a specific email address. Catches credential stuffing against a single account from rotating IPs. maxBans: 2.

Composite guard

Limits the number of attempts from a specific IP targeting a specific email. The most precise guard. maxBans: 3.

On successful login, the controller resets all limiters and clears all consecutive caches for the IP, email, and composite key:

consecutiveForIp.delete(req.ip!)
consecutive429.delete(compositeKey)
consecutiveForEmail.delete(email)
await resetLimitersUni(compositeKey)

Token Rotation

The rotation controller applies three guards but uses a different strategy than login. It does not key on email (the rotation endpoint does not accept email input). Instead, it keys on the SHA-256 hash of the refresh token.

IP guard

Limits the total number of rotation attempts from a single IP. maxBans: 1 (strict, single-strike block).

Token hash guard

Limits attempts using the same refresh token hash. Catches replay attacks where a stolen token is used repeatedly. maxBans: 1.

Composite guard

Limits attempts from a specific IP with a specific token hash. maxBans: 1.

After successful rotation, the controller does not reset limiters. Instead, it blocks the old token hash and composite key for 3 days:

await refreshTokenLimiter.block(hashedToken, 60 * 60 * 24 * 3)
await refreshAccessTokenLimiter.block(compositeKey, 60 * 60 * 24 * 3)

This prevents the old token from being used again even if it has not yet been revoked in the database. The block acts as a fast-path denial layer in front of the database revocation check.

Token rotation blocks the old token hash rather than resetting limiters. This is intentional. Unlike login (where a successful attempt proves the user is legitimate), rotation consumes a token that should never be reused. Blocking the hash ensures that even a race condition between two concurrent rotation attempts with the same token results in the second attempt being rejected.

Signup

The signup controller applies three guards:

IP union guard

A union limiter (burst + slow) keyed on IP. Prevents mass-registration bots from a single source. maxBans: 2.

Composite guard

A union limiter keyed on ${IP}_${email}. Prevents repeated signup attempts for the same email from the same IP. maxBans: 2.

Email guard

A standalone limiter keyed on the email address. Prevents repeated signup attempts for the same email from different IPs. maxBans: 2.

OAuth

The OAuth controller applies three guards:

IP union guard

A union limiter (burst + slow) keyed on IP. maxBans: 1.

Subject guard

A standalone limiter keyed on the OAuth provider's subject ID (sub claim). Catches abuse targeting a specific OAuth account. maxBans: 2.

Composite guard

A standalone limiter keyed on ${IP}_${sub}. maxBans: 2.

Password Reset Initiation

The password reset initiation controller (POST /auth/forgot-password) applies four guards:

Global email guard

A global limiter keyed on the fixed string 'global_emails'. Caps the total number of password reset emails the system sends per day. maxBans: 1.

IP guard

A standalone limiter keyed on IP. maxBans: 2.

Email guard

A standalone limiter keyed on the target email address. Prevents an attacker from flooding a single user's inbox. maxBans: 2.

Composite guard (on failure)

A union limiter keyed on ${IP}_${email}, applied only when the actual password reset lookup fails. This avoids penalizing legitimate requests that pass validation. maxBans: 3.

Email MFA Initiation

The email MFA flow controller applies four guards:

Global email guard

Same global limiter as password reset. Shared key 'global_emails' caps system-wide email sends. maxBans: 1.

IP guard

A standalone limiter keyed on IP. maxBans: 2.

Identity guard

A standalone limiter keyed on a combination of the random token and the MFA reason. Prevents repeated MFA triggers for the same session. maxBans: 2.

Composite guard

A union limiter keyed on ${IP}_${random}_${reason}. maxBans: 3.

Magic link verification (MFA, password reset, custom MFA) applies a single IP union guard. The union limiter contains a burst limiter (2 points per second) and a slow limiter (30 points per 30 minutes). maxBans: 1.

Temporary Post Routes

The password change and MFA code submission endpoints apply three guards:

JTI guard

A standalone limiter keyed on the JWT's jti claim. If the JTI has already been consumed by a previous request, the guard blocks immediately. This is configured with 0 points and 0 duration so that any consumption attempt triggers a block. maxBans: 1.

IP guard

A standalone limiter keyed on IP. maxBans: 2.

Composite guard

A union limiter keyed on a composite key. maxBans: 2.


Built-In Limiter Reference

All limiter groups are configured in the rate_limiters section of the service configuration. Every group is optional. Omitting a group disables rate limiting for those endpoints and uses hardcoded defaults instead.

Each limiter within a group accepts the same five fields:

points
number required
Maximum requests allowed within the time window.
duration
number required
Time window in seconds. Points reset after this period.
blockDuration
number required
How long (in seconds) to block the key after all points are exhausted.
inMemoryBlockOnConsumed
number required
At this consumption count, the in-memory layer blocks the key without hitting MySQL.
inMemoryBlockDuration
number required
How long (in seconds) the in-memory block lasts.

loginLimiters

Protects POST /login against credential-stuffing attacks.

LimiterKeyDefault pointsDefault durationDefault blockPurpose
unionLimiter.burstLimiter${IP}_${email}11s30minBlocks rapid-fire login attempts
unionLimiter.slowLimiter${IP}_${email}51hr30minCatches slow credential stuffing
ipLimiter${IP}1524hr3hrCaps total login attempts per IP per day
emailLimiter${email}524hr5hrCaps login attempts per email per day

signupLimiters

Protects POST /signup against mass-registration bots. This group uses two union limiters instead of one.

LimiterKeyDefault pointsDefault durationDefault blockPurpose
unionLimiters.uniLimiterIp.ipLimit${IP}21s15minBlocks rapid signup bursts per IP
unionLimiters.uniLimiterIp.slowIpLimit${IP}530min15minCatches sustained signup attempts per IP
unionLimiters.uniLimiterComposite.compositeKeyLimit${IP}_${email}11s30minBlocks rapid signup for same email from same IP
unionLimiters.uniLimiterComposite.slowCompositeKeyLimit${IP}_${email}324hr24hrDaily limit for same email+IP combination
emailLimit${email}324hr24hrGlobal per-email signup cap

oauthLimiters

Protects POST /auth/OAuth/:providerName.

LimiterKeyDefault pointsDefault durationDefault blockPurpose
unionLimiter.ipLimiterBrute${IP}11s5minBlocks rapid OAuth attempts per IP
unionLimiter.ipLimiterSlow${IP}251hr30minHourly cap per IP
subLimiter${sub}55min15minLimits attempts per OAuth subject
compositeKeyLimiter${IP}_${sub}310min15minLimits attempts per IP+subject combination

tokenLimiters

Protects POST /auth/user/refresh-session (full token rotation).

LimiterKeyDefault pointsDefault durationDefault blockPurpose
unionLimiters.refreshAccessTokenLimiter.accessTokenBrute${IP}21s30minBlocks rapid access token refreshes per IP
unionLimiters.refreshAccessTokenLimiter.accessTokenSlow${IP}310min1hrCaps access token refreshes per IP over time
unionLimiters.refreshTokenLimiterUnion.refreshTokenBrute${tokenHash}21s30minBlocks rapid rotation attempts per token
unionLimiters.refreshTokenLimiterUnion.refreshTokenSlow${tokenHash}412hr12hrLong-window cap per token hash
refreshTokenLimiter${tokenHash}312hr15hrStandalone token hash limiter for the composite guard
The token limiter group also creates an internal blackList limiter (20 points, 24hr duration, 3-day block). This limiter is used by the rotation controller to block consumed token hashes for 3 days after successful rotation, preventing any reuse of old tokens even if database revocation has not propagated yet.

linkVerificationLimiter

Protects magic link verification endpoints: MFA email links, password reset links, and custom MFA flow links.

LimiterKeyDefault pointsDefault durationDefault blockPurpose
unionLimiter.burstLimiter${IP}21s15minBlocks rapid link verification attempts
unionLimiter.slowLimiter${IP}3030min30minCaps total link verification attempts per IP

initPasswordResetLimiters

Protects POST /auth/forgot-password.

LimiterKeyDefault pointsDefault durationDefault blockPurpose
unionLimiters.limit${IP}_${email}11s30minBlocks rapid password resets per IP+email
unionLimiters.longLimiter${IP}_${email}430min15minSustained cap per IP+email
ipLimiter${IP}524hr4hrDaily cap per IP
emailLimiter${email}524hr4hrDaily cap per email

emailMfaLimiters

Protects MFA email sending endpoints.

LimiterKeyDefault pointsDefault durationDefault blockPurpose
unionLimiters.limit${IP}_${identifier}11s30minBlocks rapid MFA triggers per IP+identifier
unionLimiters.longLimiter${IP}_${identifier}430min15minSustained cap per IP+identifier
ipLimiter${IP}524hr4hrDaily cap per IP
userIdLimiteruser_${userId}824hr12hrDaily cap per user account
globalEmailLimiterglobal_emails80024hr24hrSystem-wide daily email send cap
The globalEmailLimiter is shared between the password reset and email MFA flows. Both controllers import it from the email MFA limiter module and guard against the same 'global_emails' key. If the system hits 800 password reset and MFA emails combined in 24 hours, all further email-sending endpoints are blocked for 24 hours regardless of which specific flow triggered them.

tempPostRoutesLimiters

Protects password change submissions and MFA code submissions (the POST endpoints that consume temporary magic links).

LimiterKeyDefault pointsDefault durationDefault blockPurpose
unionLimiters.limit${compositeKey}11s30minBlocks rapid submissions per composite key
unionLimiters.slowLimit${compositeKey}510min10minSustained cap per composite key
ipLimit${IP}610min10minCaps total submissions per IP
usedJtiLimiter${jti}00s20minZero-point limiter. Any consumption blocks the JTI for 20 minutes. Prevents reuse of temporary tokens.

Building a Custom Rate Limiter

When you need to protect additional endpoints in your own application, you can compose the same utilities the IAM service uses internally. All factory functions and helpers are exported from @riavzon/auth.

Define the limiter module

Create a module in your project that builds the limiters once and reuses them for the lifetime of the process. The singleton pattern with lazy initialization ensures the MySQL pool and rate limiter tables are created only on first use:

import {
  makeRateLimiter,
  unionLimiter,
  type BlockableUnion,
} from '@riavzon/auth'
import type { RateLimiterMySQL, RateLimiterMemory, RLWrapperBlackAndWhite } from 'rate-limiter-flexible'
import type { Pool } from 'mysql2'

interface LimiterBundle {
  uniLimiter: BlockableUnion | RLWrapperBlackAndWhite
  ipLimiter: RateLimiterMySQL | RateLimiterMemory
  resetAll(key: string): Promise<void>
}

let instance: LimiterBundle | null = null

function build(pool: Pool, dbName: string): LimiterBundle {
  const burst = makeRateLimiter(true, false, {
    dbName,
    storeClient: pool,
    storeType: 'mysql2',
    tableName: 'my_endpoint',
    keyPrefix: 'my_endpoint_burst',
    points: 2,
    duration: 1,
    blockDuration: 1800,
    inMemoryBlockOnConsumed: 3,
    inMemoryBlockDuration: 1800,
  })

  const slow = makeRateLimiter(true, false, {
    dbName,
    storeClient: pool,
    storeType: 'mysql2',
    tableName: 'my_endpoint',
    keyPrefix: 'my_endpoint_slow',
    points: 10,
    duration: 3600,
    blockDuration: 3600,
    inMemoryBlockOnConsumed: 11,
    inMemoryBlockDuration: 3600,
  })

  const ipLimiter = makeRateLimiter(true, false, {
    dbName,
    storeClient: pool,
    storeType: 'mysql2',
    tableName: 'my_endpoint',
    keyPrefix: 'my_endpoint_ip',
    points: 20,
    duration: 86400,
    blockDuration: 14400,
    inMemoryBlockOnConsumed: 21,
    inMemoryBlockDuration: 14400,
  })

  return {
    uniLimiter: unionLimiter([burst, slow], false),
    ipLimiter,
    resetAll: async (key: string) => {
      await Promise.all([
        burst.delete(key),
        slow.delete(key),
        ipLimiter.delete(key),
      ])
    },
  }
}

export function init(pool: Pool, dbName: string): void {
  if (!instance) instance = build(pool, dbName)
}

export function getLimiters(): LimiterBundle {
  if (!instance) throw new Error('Limiters not initialized. Call init() first.')
  return instance
}

Call init(pool, dbName) once at application startup (after your MySQL pool is ready). From then on, every call to getLimiters() returns the same singleton.

Create consecutive caches

Each guard() call needs its own LRU cache to track consecutive failures. Declare them at the module level so they persist across requests:

import { makeConsecutiveCache } from '@riavzon/auth'

const consecutiveForIp = makeConsecutiveCache<{ countData: number }>(500, 60_000)
const consecutiveForComposite = makeConsecutiveCache<{ countData: number }>(500, 1800_000)

Apply guards in sequence

Call guard() for each limiter layer. Order the checks from cheapest to most specific: IP first, then identity, then composite.

import { guard } from '@riavzon/auth'
import { getLimiters } from './myEndpointLimiter.js'

export async function myController(req: Request, res: Response) {
  const { uniLimiter, ipLimiter } = getLimiters()
  const log = getLogger().child({ service: 'myApp', branch: 'myEndpoint' })

  // Guard 1: IP
  if (!(await guard(ipLimiter, req.ip!, consecutiveForIp, 2, 'ip', log, res))) return

  // Guard 2: Composite (IP + identity)
  const compositeKey = `${req.ip!}_${email}`
  if (!(await guard(uniLimiter, compositeKey, consecutiveForComposite, 3, 'composite', log, res))) return

  // ... business logic ...

  // On success: clear strikes and reset limiter points
  consecutiveForIp.delete(req.ip!)
  consecutiveForComposite.delete(compositeKey)
  await getLimiters().resetAll(compositeKey)
}

If any guard() call returns false, the 429 response has already been sent. The controller returns immediately without running the remaining guards or the business logic.


Response Format

When any guard or limiter rejects a request, the service returns:

FieldValue
Status429 Too Many Requests
HeaderRetry-After: {seconds}
Body{ "error": "Too many requests", "retry": {seconds} }

The Retry-After value is computed as Math.ceil(msBeforeNext / 1000) with a minimum of 1 second. For keys in the global block cache, the value is the block expiration timestamp or the string 'permanent'.


Summary

ConceptDescription
Union limiterPairs a burst limiter (short window, low points) with a slow limiter (long window, higher points). Both must pass.
GuardConsumes a limiter, tracks strikes in a consecutive cache, and permanently blocks keys that exceed maxBans.
Consecutive cacheLRU cache tracking how many times a key has been rate limited. Separate from the limiter's own point tracking.
Block cacheGlobal 7-day LRU cache of permanently blocked keys. Checked before limiter consumption for fast-path rejection.
Key strategiesIP, identity (email/sub/token hash/userId), composite (IP_identity), and global (fixed string).
Reset on successLogin, signup, and OAuth reset all limiters and caches on successful authentication.
Block on successToken rotation blocks the old token hash for 3 days instead of resetting.
StorageMySQL (persistent, shared across instances) + in-memory (fast path, per-process).
Response429 with Retry-After header and JSON body.
Logo