Rate Limiting
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:
| Layer | Storage | Purpose |
|---|---|---|
| Primary | MySQL | Persistent state. Survives process restarts. Shared across multiple IAM instances behind a load balancer. |
| In-memory | Node.js process memory | Fast 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:
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,
})
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.true, wraps the limiter in RLWrapperBlackAndWhite. Whitelisted keys bypass rate limiting entirely. The default whitelist check matches 10.10.10.10 (a test IP).sql is true.Settings Fields
| Field | Type | Required | Description |
|---|---|---|---|
keyPrefix | string | Yes | Unique prefix for this limiter in the store. Used as the MySQL table column key and the in-memory map key. |
points | number | Yes | Maximum number of requests allowed within the duration window. |
duration | number | Yes | Time window in seconds. Points reset after this period. |
blockDuration | number | Yes | How long, in seconds, to block the key after all points are consumed. 0 means permanent block. |
inMemoryBlockOnConsumed | number | No | When consumed points reach this threshold, the in-memory layer blocks the key without checking MySQL. Typically set slightly above points to catch bursts. |
inMemoryBlockDuration | number | No | How long, in seconds, the in-memory block lasts. Usually matches blockDuration. |
dbName | string | When sql: true | The MySQL database name for this limiter's storage. |
storeClient | Pool | When sql: true | The callback-based MySQL pool from poolForLibrary(). |
storeType | string | When sql: true | Always 'mysql2'. Identifies the pool type for rate-limiter-flexible. |
tableName | string | When sql: true | The 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)
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:
| Method | Description |
|---|---|
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. |
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
RateLimiterMemory, RateLimiterMySQL, or a BlockableUnion from unionLimiter().${ip}_${email}. Keys longer than 255 characters are automatically SHA-256 hashed before use.guard() call that fails increments the counter. Successful calls do not clear the counter (that is the controller's responsibility).1 for high-confidence keys (token hashes), 2 for identity keys (IPs, emails), and 3 for composite keys.'ip', 'email', 'compositeKey').429 response directly, including the Retry-After header and a JSON error body.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.
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:
| Response | Value |
|---|---|
| Status | 429 Too Many Requests |
| Header | Retry-After: {seconds} (computed as Math.ceil(msBeforeNext / 1000), minimum 1) |
| Body | { error: 'Too many requests', retry: {seconds} } |
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])
.delete(key) on each one.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
)
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)
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.
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.
Link Verification
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:
loginLimiters
Protects POST /login against credential-stuffing attacks.
| Limiter | Key | Default points | Default duration | Default block | Purpose |
|---|---|---|---|---|---|
unionLimiter.burstLimiter | ${IP}_${email} | 1 | 1s | 30min | Blocks rapid-fire login attempts |
unionLimiter.slowLimiter | ${IP}_${email} | 5 | 1hr | 30min | Catches slow credential stuffing |
ipLimiter | ${IP} | 15 | 24hr | 3hr | Caps total login attempts per IP per day |
emailLimiter | ${email} | 5 | 24hr | 5hr | Caps login attempts per email per day |
signupLimiters
Protects POST /signup against mass-registration bots. This group uses two union limiters instead of one.
| Limiter | Key | Default points | Default duration | Default block | Purpose |
|---|---|---|---|---|---|
unionLimiters.uniLimiterIp.ipLimit | ${IP} | 2 | 1s | 15min | Blocks rapid signup bursts per IP |
unionLimiters.uniLimiterIp.slowIpLimit | ${IP} | 5 | 30min | 15min | Catches sustained signup attempts per IP |
unionLimiters.uniLimiterComposite.compositeKeyLimit | ${IP}_${email} | 1 | 1s | 30min | Blocks rapid signup for same email from same IP |
unionLimiters.uniLimiterComposite.slowCompositeKeyLimit | ${IP}_${email} | 3 | 24hr | 24hr | Daily limit for same email+IP combination |
emailLimit | ${email} | 3 | 24hr | 24hr | Global per-email signup cap |
oauthLimiters
Protects POST /auth/OAuth/:providerName.
| Limiter | Key | Default points | Default duration | Default block | Purpose |
|---|---|---|---|---|---|
unionLimiter.ipLimiterBrute | ${IP} | 1 | 1s | 5min | Blocks rapid OAuth attempts per IP |
unionLimiter.ipLimiterSlow | ${IP} | 25 | 1hr | 30min | Hourly cap per IP |
subLimiter | ${sub} | 5 | 5min | 15min | Limits attempts per OAuth subject |
compositeKeyLimiter | ${IP}_${sub} | 3 | 10min | 15min | Limits attempts per IP+subject combination |
tokenLimiters
Protects POST /auth/user/refresh-session (full token rotation).
| Limiter | Key | Default points | Default duration | Default block | Purpose |
|---|---|---|---|---|---|
unionLimiters.refreshAccessTokenLimiter.accessTokenBrute | ${IP} | 2 | 1s | 30min | Blocks rapid access token refreshes per IP |
unionLimiters.refreshAccessTokenLimiter.accessTokenSlow | ${IP} | 3 | 10min | 1hr | Caps access token refreshes per IP over time |
unionLimiters.refreshTokenLimiterUnion.refreshTokenBrute | ${tokenHash} | 2 | 1s | 30min | Blocks rapid rotation attempts per token |
unionLimiters.refreshTokenLimiterUnion.refreshTokenSlow | ${tokenHash} | 4 | 12hr | 12hr | Long-window cap per token hash |
refreshTokenLimiter | ${tokenHash} | 3 | 12hr | 15hr | Standalone token hash limiter for the composite guard |
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.
| Limiter | Key | Default points | Default duration | Default block | Purpose |
|---|---|---|---|---|---|
unionLimiter.burstLimiter | ${IP} | 2 | 1s | 15min | Blocks rapid link verification attempts |
unionLimiter.slowLimiter | ${IP} | 30 | 30min | 30min | Caps total link verification attempts per IP |
initPasswordResetLimiters
Protects POST /auth/forgot-password.
| Limiter | Key | Default points | Default duration | Default block | Purpose |
|---|---|---|---|---|---|
unionLimiters.limit | ${IP}_${email} | 1 | 1s | 30min | Blocks rapid password resets per IP+email |
unionLimiters.longLimiter | ${IP}_${email} | 4 | 30min | 15min | Sustained cap per IP+email |
ipLimiter | ${IP} | 5 | 24hr | 4hr | Daily cap per IP |
emailLimiter | ${email} | 5 | 24hr | 4hr | Daily cap per email |
emailMfaLimiters
Protects MFA email sending endpoints.
| Limiter | Key | Default points | Default duration | Default block | Purpose |
|---|---|---|---|---|---|
unionLimiters.limit | ${IP}_${identifier} | 1 | 1s | 30min | Blocks rapid MFA triggers per IP+identifier |
unionLimiters.longLimiter | ${IP}_${identifier} | 4 | 30min | 15min | Sustained cap per IP+identifier |
ipLimiter | ${IP} | 5 | 24hr | 4hr | Daily cap per IP |
userIdLimiter | user_${userId} | 8 | 24hr | 12hr | Daily cap per user account |
globalEmailLimiter | global_emails | 800 | 24hr | 24hr | System-wide daily email send cap |
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).
| Limiter | Key | Default points | Default duration | Default block | Purpose |
|---|---|---|---|---|---|
unionLimiters.limit | ${compositeKey} | 1 | 1s | 30min | Blocks rapid submissions per composite key |
unionLimiters.slowLimit | ${compositeKey} | 5 | 10min | 10min | Sustained cap per composite key |
ipLimit | ${IP} | 6 | 10min | 10min | Caps total submissions per IP |
usedJtiLimiter | ${jti} | 0 | 0s | 20min | Zero-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:
| Field | Value |
|---|---|
| Status | 429 Too Many Requests |
| Header | Retry-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
| Concept | Description |
|---|---|
| Union limiter | Pairs a burst limiter (short window, low points) with a slow limiter (long window, higher points). Both must pass. |
| Guard | Consumes a limiter, tracks strikes in a consecutive cache, and permanently blocks keys that exceed maxBans. |
| Consecutive cache | LRU cache tracking how many times a key has been rate limited. Separate from the limiter's own point tracking. |
| Block cache | Global 7-day LRU cache of permanently blocked keys. Checked before limiter consumption for fast-path rejection. |
| Key strategies | IP, identity (email/sub/token hash/userId), composite (IP_identity), and global (fixed string). |
| Reset on success | Login, signup, and OAuth reset all limiters and caches on successful authentication. |
| Block on success | Token rotation blocks the old token hash for 3 days instead of resetting. |
| Storage | MySQL (persistent, shared across instances) + in-memory (fast path, per-process). |
| Response | 429 with Retry-After header and JSON body. |