Skip to main content

Rate Limiting and Retry Restrictions

From the CTO — Required reading for all product and engineering teams. An endpoint with no rate limit is an open invitation. Brute-force attacks on login, credential stuffing on account creation, and automated abuse of payment flows are not exotic threats — they happen to every product at scale. Rate limiting is a non-negotiable layer of defense on any sensitive operation.


Table of Contents

  1. Why This Matters
  2. The Standard: Limits by Endpoint Category
  3. Rate Limiting Strategies
  4. Implementation
  5. Login and Authentication Endpoints
  6. Payment and Checkout Endpoints
  7. Response Format and Headers
  8. Forbidden Patterns
  9. Quick Reference

Why This Matters

Brute force attacks are automated and fast

An unprotected login endpoint can be attacked at thousands of requests per second. A credential stuffing attack feeds a list of leaked username/password pairs from other breaches into your login endpoint. Without rate limiting, an attacker can test millions of combinations in minutes. Because users reuse passwords, a significant percentage of those attempts will succeed. Rate limiting on failed login attempts is the primary defence against this attack class.

Payment endpoints are targets for fraud automation

Automated bots test stolen card details at scale. Card testing attacks use scripts to submit small payment attempts with stolen card numbers, rotating through combinations of CVV and expiry date until one succeeds. Each failed attempt costs nothing to the attacker but triggers a chargeback fee and a risk flag from the payment processor when a real transaction eventually goes through. Without rate limiting, a single compromised IP can test hundreds of cards per minute against your checkout flow.

Abuse amplifies infrastructure cost

Unrestricted endpoints are also a denial-of-service vector. Even without malicious intent, a misconfigured integration or a runaway retry loop on a client can flood a sensitive endpoint. Rate limiting protects both security and stability. An endpoint that processes payments or sends verification emails should never be callable thousands of times per minute by a single source.

Retry logic without backoff causes cascades

Aggressive client retries turn a partial outage into a full one. When a service is degraded and clients retry immediately and repeatedly, request volume increases at exactly the wrong moment. This makes recovery slower and can push a degraded service into a full outage. All client-side retry logic must use exponential backoff with jitter, and servers must enforce maximum retry thresholds independently of client behaviour.

Architectural warning: Rate limiting in the application layer alone is insufficient. A determined attacker will rotate IPs and user agents. Application-layer limits are the last line, not the only line. Pair them with WAF rules, bot detection, and anomaly alerting at the infrastructure level.


The Standard: Limits by Endpoint Category

These limits are the minimum required thresholds. Teams may tighten them based on observed traffic patterns. Loosening them requires a security review.

Endpoint categoryKeyWindowMax requestsBlock duration
Login (by IP)login:ip:{ip}15 minutes20 attempts15 minutes
Login (by account)login:user:{userId}15 minutes5 failed attempts30 minutes
Password reset requestpwd-reset:ip:{ip}1 hour5 attempts1 hour
Email / OTP verificationotp:ip:{ip}10 minutes10 attempts15 minutes
Payment / checkoutpayment:ip:{ip}1 hour10 attempts1 hour
Payment (by account)payment:user:{userId}1 hour10 attempts1 hour
General API (authenticated)api:user:{userId}1 minute120 requests— (429 until window resets)
General API (unauthenticated)api:ip:{ip}1 minute30 requests— (429 until window resets)

Per-account limits are additive with per-IP limits. Both keys are checked on every request. If either limit is exceeded, the request is rejected.


Rate Limiting Strategies

Fixed window

The simplest strategy. Count requests in a fixed time window (e.g. 00:00–01:00, 01:00–02:00). Cheap to implement but has a burst problem: an attacker can send the full quota in the last second of one window and the first second of the next, doubling the effective rate at the boundary.

Use for: general API rate limiting where burst tolerance is acceptable.

Sliding window

Tracks the count over the last N seconds relative to the current request time. No burst problem at window boundaries. Slightly more expensive in storage (requires a sorted set or similar structure).

Use for: login, payment, and all security-sensitive endpoints.

Token bucket

A bucket holds up to N tokens. Each request consumes one token. Tokens refill at a fixed rate. Allows controlled bursts (up to bucket capacity) while enforcing a sustained rate limit.

Use for: API endpoints where brief legitimate bursts are expected (e.g. a client syncing after reconnecting).

StrategyBurst problemStorage costRecommended for
Fixed windowYesLowGeneral API (low risk)
Sliding windowNoMediumLogin, payment, OTP
Token bucketControlledMediumAPI with legitimate burst needs

Default to sliding window for all security-sensitive endpoints.

Key selection

Choose the rate limit key based on the risk surface:

KeyWhen to use
IP addressUnauthenticated endpoints (login, registration, password reset)
User IDAuthenticated endpoints — prevents abuse even after IP rotation
IP + User IDPayment and checkout — check both, reject if either is exceeded
Device fingerprintSupplement to IP when available (bot detection, mobile)

Implementation

Approved library: rate-limiter-flexible

npm install rate-limiter-flexible ioredis

rate-limiter-flexible supports Redis-backed sliding window and token bucket strategies, atomic operations (no race conditions), and in-memory fallback. It is the approved library for all rate limiting in Node.js services.

Do not use:

  • express-rate-limit with its default in-memory store for distributed services — it does not share state across instances.
  • Custom counters in a relational database — not atomic enough under load, and slow.
  • No rate limiting at all on the assumption that a WAF or API gateway handles it — the application layer must enforce its own limits.

Redis-backed sliding window limiter

import { RateLimiterRedis } from 'rate-limiter-flexible';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

export const loginByIpLimiter = new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'login:ip',
points: 20, // max attempts
duration: 900, // 15 minutes (seconds)
blockDuration: 900, // block for 15 minutes after limit is hit
});

export const loginByUserLimiter = new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'login:user',
points: 5, // max failed attempts per account
duration: 900, // 15 minutes
blockDuration: 1800, // block for 30 minutes
});

Rate limit middleware

import { RateLimiterRes } from 'rate-limiter-flexible';

export function rateLimitLogin(req: Request, res: Response, next: NextFunction) {
const ip = req.ip;

loginByIpLimiter.consume(ip)
.then(() => next())
.catch((rejection: RateLimiterRes) => {
res.set('Retry-After', String(Math.ceil(rejection.msBeforeNext / 1000)));
next(new TooManyRequestsError());
});
}

Consuming on failed login only

For the per-account limit, consume a point only on a failed authentication attempt. A successful login resets the counter.

export async function login(req: Request, res: Response, next: NextFunction) {
const { email, password } = req.body;

try {
const user = await userService.findByEmail(email);
const valid = user && await bcrypt.compare(password, user.passwordHash);

if (!valid) {
// Consume a point on failure — this is what brute-force protection tracks
await loginByUserLimiter.consume(email).catch(() => {});
return next(new UnauthorizedError('Invalid credentials.'));
}

// Success — reset the per-account counter
await loginByUserLimiter.delete(email);

const token = issueToken(user);
res.cookie('access_token', token, { httpOnly: true, secure: true, sameSite: 'strict' });
res.status(200).json({ expiresAt: Date.now() + 15 * 60 * 1000 });

} catch (err) {
next(err);
}
}

Login and Authentication Endpoints

Two-key enforcement

Every login request checks both the IP-based limit and the account-based limit. Both must pass.

export async function loginRateLimit(req: Request, res: Response, next: NextFunction) {
const ip = req.ip;
const email = req.body.email as string | undefined;

try {
await loginByIpLimiter.consume(ip);
if (email) await loginByUserLimiter.consume(email);
next();
} catch (rejection: unknown) {
const r = rejection as RateLimiterRes;
res.set('Retry-After', String(Math.ceil(r.msBeforeNext / 1000)));
next(new TooManyRequestsError());
}
}

Account lockout vs. rate limit

These are different mechanisms.

MechanismTriggerDurationManaged by
Rate limitToo many requests in a windowTemporary (window duration)Redis — auto-expires
Account lockoutSustained failed login pattern detectedUntil admin review or user verifies identityDatabase flag + manual or automated unlock

Rate limiting is the first line — fast, automated, self-expiring. Account lockout is an escalation for persistent attack patterns. Implement both independently.

Do not leak user existence

Return the same response and take the same time whether the email exists or not. Timing differences and different error messages allow attackers to enumerate valid accounts.

// ✅ Same message, same timing regardless of whether the user exists
if (!valid) {
return next(new UnauthorizedError('Invalid email or password.'));
}

// ❌ Different messages reveal whether the account exists
if (!user) return next(new UnauthorizedError('Account not found.'));
if (!valid) return next(new UnauthorizedError('Incorrect password.'));

Payment and Checkout Endpoints

Payment endpoints require both per-IP and per-user limits, enforced simultaneously. A user legitimately making multiple purchases in a short window is rare; automated card testing is not.

Per-IP and per-user payment limiter

export const paymentByIpLimiter = new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'payment:ip',
points: 10,
duration: 3600, // 1 hour
blockDuration: 3600,
});

export const paymentByUserLimiter = new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'payment:user',
points: 10,
duration: 3600,
blockDuration: 3600,
});

Consume on declined transaction, not on every attempt

A declined payment is a stronger signal than any request. Consume a point from the limit only when the payment processor returns a decline or an error — not on every checkout page load.

export async function processPayment(userId: string, ip: string, paymentDetails: PaymentInput) {
const result = await paymentProvider.charge(paymentDetails);

if (!result.success) {
// Consume on decline — this is the card-testing signal
await Promise.all([
paymentByIpLimiter.consume(ip).catch(() => {}),
paymentByUserLimiter.consume(userId).catch(() => {}),
]);

// Check if either limit is now exceeded
const [ipStatus, userStatus] = await Promise.all([
paymentByIpLimiter.get(ip),
paymentByUserLimiter.get(userId),
]);

if ((ipStatus?.remainingPoints ?? 1) === 0 || (userStatus?.remainingPoints ?? 1) === 0) {
log.warn('PAYMENT_RATE_LIMIT_HIT', { userId, ip });
throw new TooManyRequestsError('Too many failed payment attempts. Please try again later.');
}

throw new PaymentDeclinedError(result.declineCode);
}

// Success — optionally reset the counters
await Promise.all([
paymentByIpLimiter.delete(ip),
paymentByUserLimiter.delete(userId),
]);

return result;
}

Additional signals for payment abuse

Rate limiting by count is a floor, not a ceiling. Also monitor and alert on:

SignalWhat it indicates
Multiple distinct card numbers from one user in a short windowCard testing
Multiple users from the same IP making payment attemptsBot or proxy
High decline rate from a specific IP rangeFraud cluster
Velocity on small amounts (< $1.00) followed by a larger chargeCard testing before larger fraud

These signals feed into your fraud alerting system and may trigger manual review or temporary blocks outside the standard rate limit logic.


Response Format and Headers

A rate-limited response must return HTTP 429 Too Many Requests with the standard error payload and Retry-After header.

Response body

{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Please wait before trying again.",
"requestId": "req_01j2k3m4n5p6q7r8s9t0"
}
}

Required headers

HeaderValuePurpose
Retry-AfterSeconds until the limit resetsTells the client when it can retry
X-RateLimit-LimitMax requests in the windowInformational — helps clients self-throttle
X-RateLimit-RemainingRequests remaining in the current windowInformational
X-RateLimit-ResetUnix timestamp when the window resetsInformational
// ✅ Set all rate limit headers on every response (not just on 429)
export function setRateLimitHeaders(res: Response, rateLimiterRes: RateLimiterRes, limit: number) {
res.set({
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': String(rateLimiterRes.remainingPoints),
'X-RateLimit-Reset': String(Math.ceil((Date.now() + rateLimiterRes.msBeforeNext) / 1000)),
});
}

// On 429 — also set Retry-After
res.set('Retry-After', String(Math.ceil(rejection.msBeforeNext / 1000)));
res.status(429).json({ error: { code: 'RATE_LIMITED', message: '...', requestId } });

Client retry behaviour

Clients must respect the Retry-After header and use exponential backoff with jitter. Never retry immediately on a 429.

// ✅ Exponential backoff with jitter
async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 3): Promise<Response> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const res = await fetch(url, options);
if (res.status !== 429) return res;

const retryAfter = parseInt(res.headers.get('Retry-After') ?? '5', 10);
const jitter = Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000 + jitter));
}
throw new Error('Max retries exceeded');
}

// ❌ Immediate retry on 429
if (res.status === 429) return fetch(url, options); // amplifies the problem

Forbidden Patterns

PatternWhy it is forbiddenUse instead
In-memory rate limiter in a multi-instance serviceState is not shared — each instance has its own countersRedis-backed limiter
Rate limiting only at the API gatewayApplication bypasses (internal calls, jobs) are unprotectedApplication-layer enforcement + gateway
Same error message timing difference for "not found" vs. "wrong password"Allows account enumerationSame response, same timing
Immediate retry on 429Amplifies load on a degraded endpointExponential backoff + respect Retry-After
Consuming a limit point on every request instead of on failureBlocks legitimate usersConsume only on the failure event
Per-IP only limits on authenticated endpointsAttacker rotates IPs after authenticationPer-user limits on authenticated endpoints
Unlimited retries on payment declinesEnables card testingPer-IP and per-user limits consumed on decline
Logging the full card number or CVV in rate limit logsPCI DSS violationLog only masked card suffix and decline code

Quick Reference

// ✅ Redis-backed sliding window limiter
const limiter = new RateLimiterRedis({
storeClient: redis, keyPrefix: 'login:ip',
points: 20, duration: 900, blockDuration: 900,
});

// ✅ Consume and handle rejection
limiter.consume(key)
.then(() => next())
.catch((r: RateLimiterRes) => {
res.set('Retry-After', String(Math.ceil(r.msBeforeNext / 1000)));
next(new TooManyRequestsError());
});

// ✅ Consume on failure, delete on success (login)
if (!valid) { await loginByUserLimiter.consume(email).catch(() => {}); }
else { await loginByUserLimiter.delete(email); }

// ✅ 429 response body
{ "error": { "code": "RATE_LIMITED", "message": "Too many requests...", "requestId": "..." } }

// ❌ In-memory store in multi-instance service
// ❌ Consume on every request (not just failure)
// ❌ Different error messages for "user not found" vs "wrong password"
// ❌ Immediate retry on 429
TaskHow
Rate limit login by IPRateLimiterRedis, 20 attempts / 15 min, block 15 min
Rate limit login by accountRateLimiterRedis, 5 failed attempts / 15 min, block 30 min
Consume login limitOn failed attempt only — delete key on success
Rate limit paymentPer-IP and per-user, 10 declined / 1 hour, block 1 hour
Consume payment limitOn payment provider decline only
Signal 429 to clientHTTP 429 + Retry-After header + RATE_LIMITED error code
Client retry on 429Read Retry-After, wait with exponential backoff + jitter
Prevent account enumerationSame error message and response time for all auth failures

Questions or edge cases not covered here? Open a discussion in the #engineering-standards channel.