AuthRateLimiter from auth-rate-limit.tsSliding-Window Auth Protection
The AuthRateLimiter protects the Gateway from brute-force credential attacks using a per-IP sliding-window counter with configurable lockouts, scope-based isolation, and automatic stale-entry pruning.
AuthRateLimiter from auth-rate-limit.tscreateAuthRateLimiter(config?)Rate limiting is the first check in the auth pipeline. Before any credential evaluation, the AuthRateLimiter determines whether the client IP has exceeded its attempt budget. This prevents automated attacks from overwhelming the auth system.
exemptLoopback option (default: true) allows 127.0.0.1 to bypass rate limiting for local development.
flowchart TB
REQ["Incoming Request
clientIp + scope"]
NORM["normalizeRateLimitClientIp(ip)"]
LOOP["Is loopback?"]
EXEMPT["exemptLoopback?"]
ENTRY["Lookup RateLimitEntry
for ip+scope"]
LOCKED["lockedUntil > now?"]
WINDOW["Filter attempts
within windowMs"]
COUNT["attempts.length < maxAttempts?"]
ALLOW["RateLimitCheckResult
{allowed: true, remaining}"]
DENY["RateLimitCheckResult
{allowed: false, retryAfterMs}"]
REQ --> NORM
NORM --> LOOP
LOOP -->|yes| EXEMPT
LOOP -->|no| ENTRY
EXEMPT -->|true| ALLOW
EXEMPT -->|false| ENTRY
ENTRY --> LOCKED
LOCKED -->|yes| DENY
LOCKED -->|no| WINDOW
WINDOW --> COUNT
COUNT -->|yes| ALLOW
COUNT -->|no, triggers lockout| DENY
normalizeRateLimitClientIp() canonicalizes the IP address to prevent bypasses. IPv6-mapped IPv4 addresses are converted to their IPv4 form, and undefined IPs are mapped to a sentinel value.
import { normalizeRateLimitClientIp } from './auth-rate-limit';
normalizeRateLimitClientIp('::ffff:192.168.1.1'); // => '192.168.1.1'
normalizeRateLimitClientIp(undefined); // => 'unknown'
normalizeRateLimitClientIp('10.0.0.5'); // => '10.0.0.5'
The check() method examines the RateLimitEntry for the normalized IP+scope combination. It first checks for an active lockout, then filters the attempts[] array to only include timestamps within the current window.
const limiter = createAuthRateLimiter({
maxAttempts: 10,
windowMs: 60_000, // 60 seconds
lockoutMs: 300_000, // 5 minutes
exemptLoopback: true,
pruneIntervalMs: 60_000,
});
const result: RateLimitCheckResult = limiter.check('192.168.1.100', 'default');
// { allowed: true, remaining: 10, retryAfterMs: 0 }
When authentication fails, recordFailure() appends the current timestamp to the entry’s attempts[] array. If the array length reaches maxAttempts, a lockedUntil timestamp is set.
// Called by authorizeGatewayConnect on auth failure
limiter.recordFailure('192.168.1.100', 'default');
// After 10 failures within 60s: entry.lockedUntil = Date.now() + 300_000
A periodic timer (configured by pruneIntervalMs) calls prune() to remove entries where all attempts have expired and no lockout is active. Call dispose() to stop the timer on shutdown.
classDiagram
class RateLimitConfig {
+maxAttempts: number = 10
+windowMs: number = 60000
+lockoutMs: number = 300000
+exemptLoopback: boolean = true
+pruneIntervalMs: number = 60000
}
class RateLimitEntry {
+attempts: number[]
+lockedUntil: number?
}
class RateLimitCheckResult {
+allowed: boolean
+remaining: number
+retryAfterMs: number
}
class AuthRateLimiter {
+check(ip, scope?) RateLimitCheckResult
+recordFailure(ip, scope?) void
+reset(ip, scope?) void
+size() number
+prune() void
+dispose() void
}
AuthRateLimiter --> RateLimitConfig : configured by
AuthRateLimiter --> RateLimitEntry : manages per IP
AuthRateLimiter --> RateLimitCheckResult : returns
Scopes isolate rate limit counters so that failures in one authentication method don’t affect another.
| Constant | Value | Use Case |
|---|---|---|
AUTH_RATE_LIMIT_SCOPE_DEFAULT | "default" | General password/token auth |
AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET | "shared-secret" | Shared secret exchanges |
AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN | "device-token" | Device pairing attempts |
AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH | "hook-auth" | Webhook authentication |
| Scenario | Behavior |
|---|---|
| Loopback + exemptLoopback=true | Always allowed, no entry created |
| Loopback + exemptLoopback=false | Rate limited normally |
| IPv6-mapped IPv4 | Normalized to IPv4 before lookup |
| Undefined client IP | Mapped to "unknown" sentinel |
| Lockout active + valid creds | Still rejected until lockout expires |
| Manual reset | limiter.reset(ip, scope) clears entry |
| Gateway restart | All rate limit state is lost (in-memory) |
| Parameter | Type | Default | Description |
|---|---|---|---|
maxAttempts | number | 10 | Failed attempts before lockout |
windowMs | number | 60000 | Sliding window size (1 min) |
lockoutMs | number | 300000 | Lockout duration (5 min) |
exemptLoopback | boolean | true | Bypass for 127.0.0.1 |
pruneIntervalMs | number | 60000 | Stale entry cleanup interval |
src/gateway/
├── auth-rate-limit.ts ← AuthRateLimiter, RateLimitConfig, createAuthRateLimiter
│ normalizeRateLimitClientIp, 4 scope constants
└── auth.ts ← Calls rateLimiter.check() and recordFailure()