Skip to main content

Rate Limiting

Open Saasframe ships with a built-in rate limiting utility powered by rate-limiter-flexible. It supports in-memory and Redis backends, is globally configurable via environment variables, and integrates with the API dispatcher and route handlers.

Quick Start​

Rate limiting is enabled by default with an in-memory backend. No configuration is required for development. Authentication endpoints (login, password reset) are protected out of the box.

To switch to Redis for production (distributed, multi-instance):

RATE_LIMIT_STRATEGY=redis
REDIS_URL=redis://localhost:6379

How It Works​

Two Enforcement Paths​

Open Saasframe supports two complementary ways to enforce rate limits:

1. Metadata-Driven (Automatic)​

Declare rateLimit in your route's metadata export. The API dispatcher enforces it automatically using the client IP as the key:

// packages/core/src/modules/my_module/api/some-endpoint/route.ts
export const metadata = {
POST: {
requireAuth: true,
rateLimit: {
points: 10, // max requests per window
duration: 60, // window in seconds
keyPrefix: 'my-endpoint',
},
},
}

export async function POST(req: Request) {
// Rate limiting happens before this code runs.
// If the client exceeds the limit, the dispatcher returns 429 automatically.
}

This follows the same pattern as requireAuth and requireFeatures — zero boilerplate in the handler.

2. Handler-Level (Manual)​

For advanced key strategies (e.g., compound IP:emailHash keys), use the checkAuthRateLimit centralizer. This is how the authentication endpoints work — it handles two-layer checks (IP-only + compound), email hashing, fail-open semantics, and i18n in a single call:

import { checkAuthRateLimit, resetAuthRateLimit } from '@saasframe/core/modules/auth/lib/rateLimitCheck'
import { readEndpointRateLimitConfig } from '@saasframe/shared/lib/ratelimit/config'

const myRateLimitConfig = readEndpointRateLimitConfig('LOGIN', {
points: 5, duration: 60, blockDuration: 60, keyPrefix: 'login',
})
const myIpRateLimitConfig = readEndpointRateLimitConfig('LOGIN_IP', {
points: 20, duration: 60, blockDuration: 60, keyPrefix: 'login-ip',
})

export async function POST(req: Request) {
const form = await req.formData()
const email = String(form.get('email') ?? '')

// Two-layer rate limit — checked before validation and DB work
const { error: rateLimitError, compoundKey } = await checkAuthRateLimit({
req,
ipConfig: myIpRateLimitConfig,
compoundConfig: myRateLimitConfig,
compoundIdentifier: email,
})
if (rateLimitError) return rateLimitError

// ... auth logic ...

// Reset compound counter on successful auth
if (compoundKey) {
await resetAuthRateLimit(compoundKey, myRateLimitConfig)
}
}

The centralizer internally resolves the rate limiter service, extracts the client IP, hashes the email via computeEmailHash() (SHA-256), and wraps everything in a fail-open try/catch. If the service is unavailable or the IP cannot be determined, it allows the request through.

When to Use Which​

ApproachKey StrategyUse Case
Metadata-drivenClient IP onlyGeneral API endpoints, simple abuse prevention
Handler-levelCustom (IP+email, token, etc.)Auth endpoints, credential stuffing protection

Protected Endpoints​

The following authentication endpoints have two-layer rate limiting built in:

Layer 1 — IP-only caps total attempts from a single IP (regardless of email):

EndpointKeyPointsWindowBlock
POST /api/loginIP2060s60s
POST /api/resetIP1060s60s
POST /api/reset/confirmIP5300s—

Layer 2 — Compound caps attempts per IP + account pair (email is SHA-256 hashed):

EndpointKeyPointsWindowBlock
POST /api/loginIP:emailHash560s60s
POST /api/resetIP:emailHash360s60s

The reset-confirm endpoint uses IP-only limiting because no email is available at that point.

On successful login, the compound counter is automatically reset so legitimate users aren't penalized for prior typos. The IP-only counter is not reset.

All limits can be overridden via environment variables (see Configuration).

429 Response Format​

When a client exceeds the rate limit, the API returns:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 47
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 47

{
"error": "Too many requests. Please try again later."
}

The error message is translated according to the user's locale. Rate limit headers are only included on 429 responses.

HeaderDescription
Retry-AfterSeconds until the client can retry
X-RateLimit-LimitMaximum points allowed in the window
X-RateLimit-RemainingPoints remaining (always 0 on 429)
X-RateLimit-ResetSeconds until the window resets

Configuration​

Global Settings​

VariableDefaultDescription
RATE_LIMIT_ENABLEDtrueMaster switch. Set to false to disable all rate limiting.
RATE_LIMIT_STRATEGYmemoryBackend: memory (single instance) or redis (distributed).
RATE_LIMIT_KEY_PREFIXrlKey prefix in storage. Prevents collisions with other Redis data.
RATE_LIMIT_TRUST_PROXY_DEPTH0Number of trusted reverse proxies for X-Forwarded-For IP extraction. 0 is safe direct mode: forwarding headers are ignored and rate limiters use endpoint-scoped global fallback buckets. Positive values must match the trusted proxy chain exactly. Invalid values warn and fall back to 0.

When using redis strategy, the service reads REDIS_URL (already used by cache, events, and queue modules).

Per-Endpoint Overrides​

Compound layer (IP + hashed email):

VariableDefaultDescription
RATE_LIMIT_LOGIN_POINTS5Max login attempts per IP+account per window
RATE_LIMIT_LOGIN_DURATION60Window in seconds
RATE_LIMIT_LOGIN_BLOCK_DURATION60Block duration after exceeding limit
RATE_LIMIT_RESET_POINTS3Max password reset requests per IP+account per window
RATE_LIMIT_RESET_DURATION60Window in seconds
RATE_LIMIT_RESET_BLOCK_DURATION60Block duration after exceeding limit

IP-only layer:

VariableDefaultDescription
RATE_LIMIT_LOGIN_IP_POINTS20Max total login attempts per IP per window
RATE_LIMIT_LOGIN_IP_DURATION60Window in seconds
RATE_LIMIT_LOGIN_IP_BLOCK_DURATION60Block duration after exceeding IP limit
RATE_LIMIT_RESET_IP_POINTS10Max total reset requests per IP per window
RATE_LIMIT_RESET_IP_DURATION60Window in seconds
RATE_LIMIT_RESET_IP_BLOCK_DURATION60Block duration after exceeding IP limit
RATE_LIMIT_RESET_CONFIRM_POINTS5Max reset confirm attempts per IP per window
RATE_LIMIT_RESET_CONFIRM_DURATION300Window in seconds

Future:

VariableDefaultDescription
RATE_LIMIT_2FA_VERIFY_POINTS5Max 2FA verification attempts
RATE_LIMIT_2FA_VERIFY_DURATION300Window in seconds

Strategies​

Memory (Default)​

Uses process memory. Suitable for development and single-instance deployments. Counters are lost on app restart.

Redis​

Uses Redis for distributed rate limiting across multiple application instances. When Redis becomes unavailable, the library automatically falls back to an in-memory insurance limiter (per-instance) to maintain protection.

RATE_LIMIT_STRATEGY=redis
REDIS_URL=redis://localhost:6379

Advanced Usage​

Direct Service Access​

For advanced use cases (resetting counters, adding penalties, custom blocking), resolve the service from DI or use the global singleton:

import { getCachedRateLimiterService } from '@saasframe/core/bootstrap'

// Or via DI:
const rateLimiterService = container.resolve('rateLimiterService')

Available methods:

MethodDescription
consume(key, config)Consume 1 point. Returns { allowed, remainingPoints, msBeforeNext }.
get(key, config)Check current state without consuming a point.
delete(key, config)Reset the counter for a key (e.g., after successful login).
penalty(key, points, config)Add extra penalty points.
reward(key, points, config)Return points (reduce consumed count).
block(key, durationSec, config)Manually block a key for a duration.

Reset Counter on Success​

After a successful login, you may want to reset the rate limit counter so legitimate users aren't locked out after a few typos. The checkAuthRateLimit centralizer returns the compoundKey for this purpose:

import { resetAuthRateLimit } from '@saasframe/core/modules/auth/lib/rateLimitCheck'

// After successful auth:
if (compoundKey) {
await resetAuthRateLimit(compoundKey, loginRateLimitConfig)
}

This is a best-effort operation — it never throws and won't fail the request if the counter reset fails.

Adding Rate Limiting to a New Endpoint​

Option A: Metadata (recommended for IP-based limits)

export const metadata = {
POST: {
rateLimit: {
points: 10,
duration: 60,
keyPrefix: 'my-endpoint',
},
},
}

Option B: Handler-level (for custom keys)

import { getCachedRateLimiterService } from '@saasframe/core/bootstrap'
import { checkRateLimit, getClientIp, RATE_LIMIT_FALLBACK_KEY } from '@saasframe/shared/lib/ratelimit/helpers'
import { resolveTranslations } from '@saasframe/shared/lib/i18n/server'

const myRateLimitConfig = { points: 5, duration: 300, keyPrefix: 'my-action' }

export async function POST(req: Request) {
try {
const rateLimiterService = getCachedRateLimiterService()
if (rateLimiterService) {
const clientIp = getClientIp(req, rateLimiterService.trustProxyDepth)
const { translate } = await resolveTranslations()
const rateLimitError = await checkRateLimit(
rateLimiterService,
myRateLimitConfig,
clientIp ?? RATE_LIMIT_FALLBACK_KEY,
translate('api.errors.rateLimit', 'Too many requests. Please try again later.'),
)
if (rateLimitError) return rateLimitError
}
} catch {
// fail-open
}

// ... handler logic
}

Testing​

Rate limiting is automatically disabled when SF_INTEGRATION_TEST=true is set in the environment. The ephemeral integration-test runtime (yarn test:integration, yarn test:integration:ephemeral) sets this for you, so test suites do not have to disable limits by hand or flush counters between cases.

When running tests against a long-lived dev server (e.g. yarn dev + npx playwright test), set the flag yourself:

SF_INTEGRATION_TEST=true yarn dev

For suites that deliberately exercise rate-limit behavior, keep the integration flag off and opt in per-request with the targeted auth-endpoint escape hatch:

VariableEffect
SF_TEST_MODE=1 + SF_TEST_AUTH_RATE_LIMIT_MODE=opt-incheckAuthRateLimit becomes inert by default
Request header x-om-test-rate-limit: onRe-enables enforcement for that single request

This lets you assert both 200 and 429 paths deterministically without tearing the global limiter down.

Security Notes​

  • Two-layer protection: Auth endpoints use an IP-only layer (caps total attempts from one IP regardless of email) and a compound IP:emailHash layer (caps attempts per account). The email is SHA-256 hashed — raw emails never appear in rate limit storage keys.
  • Fail-open: Rate limit infrastructure failures never block authentication flows. The checkAuthRateLimit centralizer wraps all checks in try/catch and the service itself allows requests through on unexpected storage errors. Missing trusted client IP data is not an infrastructure failure and still consumes a fallback bucket.
  • IP extraction and X-Forwarded-For spoofing prevention: RATE_LIMIT_TRUST_PROXY_DEPTH=0 is the safe default for direct deployments and ignores both X-Forwarded-For and X-Real-IP; the standard route-handler Request does not expose a trustworthy peer socket address. Auth, metadata-driven, and checkout rate limiters therefore use endpoint-scoped global fallback keys, and compound auth keys use global:<identifierHash>. With a positive depth, getClientIp reads the Nth-from-last X-Forwarded-For entry. The configured depth must match the exact trusted proxy chain; a missing or shorter chain falls back globally instead of trusting an attacker-controlled entry. Direct-mode fallback traffic shares each endpoint's configured limit, so deployments behind a trusted proxy should set the exact depth to preserve per-client buckets and avoid unnecessary shared-bucket contention.
  • No information leakage: The generic "Too many requests" message does not reveal whether an account exists.
  • Counter reset on success: Successful login resets the compound counter (IP:emailHash) so legitimate users aren't locked out after a few typos. The IP-only counter is not reset.

File Layout​

packages/shared/src/lib/ratelimit/
├── index.ts # Public exports
├── types.ts # TypeScript types (RateLimitConfig, RateLimitResult, etc.)
├── service.ts # RateLimiterService class
├── config.ts # Environment variable reader
├── helpers.ts # checkRateLimit, getClientIp, error constants
└── __tests__/
├── service.test.ts # Service unit tests
└── helpers.test.ts # Helper + config unit tests

packages/core/src/modules/auth/lib/
├── rateLimitCheck.ts # checkAuthRateLimit / resetAuthRateLimit centralizer
├── emailHash.ts # computeEmailHash (SHA-256)
└── __tests__/
└── rateLimitCheck.test.ts # Centralizer unit tests

The service singleton and DI registration live in packages/core/src/bootstrap.ts.