Rate Limiting

Focus

Sliding-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.

Interface AuthRateLimiter from auth-rate-limit.ts
Factory createAuthRateLimiter(config?)
Algorithm Sliding window with per-IP attempt arrays
Scopes 4 independent rate limit scopes

Table of Contents

Overview

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.

⚠ Important: Rate limiting is applied before credential checks, not after. This means even valid credentials will be rejected if the IP is locked out. The exemptLoopback option (default: true) allows 127.0.0.1 to bypass rate limiting for local development.

Algorithm

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

How It Works

IP Normalization

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'

Sliding Window Check

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 }

Failure Recording

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

Automatic Pruning

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.

Data Structures

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

Rate Limit Scopes

Scopes isolate rate limit counters so that failures in one authentication method don’t affect another.

ConstantValueUse 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

Edge Cases

ScenarioBehavior
Loopback + exemptLoopback=trueAlways allowed, no entry created
Loopback + exemptLoopback=falseRate limited normally
IPv6-mapped IPv4Normalized to IPv4 before lookup
Undefined client IPMapped to "unknown" sentinel
Lockout active + valid credsStill rejected until lockout expires
Manual resetlimiter.reset(ip, scope) clears entry
Gateway restartAll rate limit state is lost (in-memory)

Configuration

ParameterTypeDefaultDescription
maxAttemptsnumber10Failed attempts before lockout
windowMsnumber60000Sliding window size (1 min)
lockoutMsnumber300000Lockout duration (5 min)
exemptLoopbackbooleantrueBypass for 127.0.0.1
pruneIntervalMsnumber60000Stale entry cleanup interval

Directory Map

Source files for this topic
src/gateway/
├── auth-rate-limit.ts     ← AuthRateLimiter, RateLimitConfig, createAuthRateLimiter
│                            normalizeRateLimitClientIp, 4 scope constants
└── auth.ts                ← Calls rateLimiter.check() and recordFailure()