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.