Skip to content

Rate Limiting

Rate limiting prevents brute force attacks by restricting how many times an action can be performed within a time window. ideal-auth provides createRateLimiter with a pluggable store interface and a built-in MemoryRateLimitStore.

import { createRateLimiter } from 'ideal-auth';
const loginLimiter = createRateLimiter({
maxAttempts: 5, // allow 5 attempts
windowMs: 15 * 60 * 1000, // per 15-minute window
});

The attempt() method increments the counter for a given key and returns whether the action is allowed:

const result = await loginLimiter.attempt('login:192.168.1.1');
result.allowed; // boolean — true if under the limit
result.remaining; // number — attempts left in the window
result.resetAt; // Date — when the window resets

Call reset() to clear the counter for a key (e.g., after a successful login):

await loginLimiter.reset('login:192.168.1.1');

Login endpoints are the most common brute force target. Rate limit by IP address, email address, or both.

Prevents an attacker from trying many passwords for any account from the same IP.

const loginLimiter = createRateLimiter({
maxAttempts: 10,
windowMs: 15 * 60 * 1000, // 15 minutes
});
// POST /api/auth/login
export async function login(req, res) {
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
const result = await loginLimiter.attempt(`login:ip:${ip}`);
if (!result.allowed) {
return res.status(429).json({
error: 'Too many login attempts. Try again later.',
retryAfter: result.resetAt.toISOString(),
});
}
const session = auth();
const success = await session.attempt({ email: req.body.email, password: req.body.password });
if (!success) {
return res.status(401).json({
error: 'Invalid credentials',
remaining: result.remaining,
});
}
// Reset the counter on successful login
await loginLimiter.reset(`login:ip:${ip}`);
return res.json({ success: true });
}

Prevents an attacker from trying many passwords for a specific account, even from rotating IPs.

const emailLimiter = createRateLimiter({
maxAttempts: 5,
windowMs: 15 * 60 * 1000,
});
// In the login handler:
const email = req.body.email.toLowerCase();
const emailResult = await emailLimiter.attempt(`login:email:${email}`);
if (!emailResult.allowed) {
return res.status(429).json({
error: 'Too many login attempts for this account. Try again later.',
retryAfter: emailResult.resetAt.toISOString(),
});
}

For the strongest protection, use both. The IP limit catches broad attacks and the email limit catches targeted attacks:

const loginLimiter = createRateLimiter({
maxAttempts: 10,
windowMs: 15 * 60 * 1000,
});
const accountLimiter = createRateLimiter({
maxAttempts: 5,
windowMs: 15 * 60 * 1000,
});
export async function login(req, res) {
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
const email = req.body.email.toLowerCase();
// Check IP limit first
const ipResult = await loginLimiter.attempt(`login:ip:${ip}`);
if (!ipResult.allowed) {
return res.status(429).json({
error: 'Too many requests. Try again later.',
retryAfter: ipResult.resetAt.toISOString(),
});
}
// Check account limit
const emailResult = await accountLimiter.attempt(`login:email:${email}`);
if (!emailResult.allowed) {
return res.status(429).json({
error: 'Too many attempts for this account. Try again later.',
retryAfter: emailResult.resetAt.toISOString(),
});
}
const session = auth();
const success = await session.attempt({ email, password: req.body.password });
if (!success) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Reset both counters on success
await loginLimiter.reset(`login:ip:${ip}`);
await accountLimiter.reset(`login:email:${email}`);
return res.json({ success: true });
}

Prevent automated account creation:

const registrationLimiter = createRateLimiter({
maxAttempts: 3,
windowMs: 60 * 60 * 1000, // 3 registrations per hour per IP
});
export async function register(req, res) {
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
const result = await registrationLimiter.attempt(`register:ip:${ip}`);
if (!result.allowed) {
return res.status(429).json({
error: 'Too many registration attempts. Try again later.',
retryAfter: result.resetAt.toISOString(),
});
}
// ... create account
}

Prevent attackers from flooding a user’s inbox or enumerating registered emails:

const resetLimiter = createRateLimiter({
maxAttempts: 3,
windowMs: 15 * 60 * 1000,
});
export async function forgotPassword(req, res) {
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
const email = req.body.email.toLowerCase();
// Rate limit by IP
const ipResult = await resetLimiter.attempt(`reset:ip:${ip}`);
if (!ipResult.allowed) {
return res.status(429).json({
error: 'Too many requests. Try again later.',
});
}
// Rate limit by email
const emailResult = await resetLimiter.attempt(`reset:email:${email}`);
if (!emailResult.allowed) {
// Return 200 to avoid leaking whether the email exists
return res.json({ message: 'If that email exists, a reset link was sent.' });
}
// ... send reset email
return res.json({ message: 'If that email exists, a reset link was sent.' });
}

See the Password Reset guide for the complete flow.

The default MemoryRateLimitStore works for development and single-process deployments, but for production you should use a shared store like Redis. Implement the RateLimitStore interface:

import type { RateLimitStore } from 'ideal-auth';
export class RedisRateLimitStore implements RateLimitStore {
private redis: RedisClient;
constructor(redis: RedisClient) {
this.redis = redis;
}
async increment(
key: string,
windowMs: number,
): Promise<{ count: number; resetAt: Date }> {
const redisKey = `ratelimit:${key}`;
const windowSeconds = Math.ceil(windowMs / 1000);
// Increment the counter. If the key doesn't exist, Redis creates it with value 1.
const count = await this.redis.incr(redisKey);
// Set expiry only on the first increment (when count is 1).
// This ensures the window starts from the first request.
if (count === 1) {
await this.redis.expire(redisKey, windowSeconds);
}
// Get the remaining TTL to calculate resetAt
const ttl = await this.redis.ttl(redisKey);
const resetAt = new Date(Date.now() + ttl * 1000);
return { count, resetAt };
}
async reset(key: string): Promise<void> {
await this.redis.del(`ratelimit:${key}`);
}
}
import { createRateLimiter } from 'ideal-auth';
import { createClient } from 'redis';
import { RedisRateLimitStore } from './redis-rate-limit-store';
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const loginLimiter = createRateLimiter({
maxAttempts: 10,
windowMs: 15 * 60 * 1000,
store: new RedisRateLimitStore(redis),
});

Any store must implement two methods:

interface RateLimitStore {
increment(
key: string,
windowMs: number,
): Promise<{ count: number; resetAt: Date }>;
reset(key: string): Promise<void>;
}
  • increment is called on every attempt(). It should atomically increment a counter for the given key and return the new count and when the window resets.
  • reset is called to clear the counter for a key (e.g., after a successful login).
// Trust the first IP in x-forwarded-for (set by your proxy)
function getClientIp(req): string {
const forwarded = req.headers['x-forwarded-for'];
if (forwarded) {
// x-forwarded-for can contain multiple IPs: "client, proxy1, proxy2"
// The first one is the client's IP (if your proxy is trusted)
return forwarded.split(',')[0].trim();
}
return req.socket.remoteAddress;
}
// Only use the socket address — x-forwarded-for can be spoofed
function getClientIp(req): string {
return req.socket.remoteAddress;
}

Some frameworks provide the client IP through their own APIs:

// Next.js (App Router)
import { headers } from 'next/headers';
const ip = headers().get('x-forwarded-for')?.split(',')[0] ?? 'unknown';
// Express (with trust proxy)
app.set('trust proxy', 1);
const ip = req.ip; // uses x-forwarded-for when trust proxy is set
// Hono
const ip = c.req.header('x-forwarded-for')?.split(',')[0] ?? 'unknown';

The built-in MemoryRateLimitStore has several limitations to be aware of:

LimitationImpact
10,000 entry capWhen the store reaches 10,000 entries, new keys are immediately rate limited until expired entries are evicted. This prevents memory exhaustion.
Single-process onlyEach process has its own store. In a multi-process or clustered deployment, rate limits are per-process, not global. An attacker can potentially get N x maxAttempts across N processes.
Resets on restartAll counters are lost when the process restarts. An attacker could trigger a restart to reset their limits.
No persistenceRate limit data is not persisted to disk.

The memory store runs automatic cleanup every 60 seconds, evicting expired entries.

  • Development and testing
  • Single-process deployments with low traffic
  • Non-critical applications where approximate rate limiting is sufficient
  • Multi-process or clustered deployments (PM2, Kubernetes, etc.)
  • Production applications where accurate rate limiting matters
  • Applications where rate limits must survive restarts

These are starting points. Adjust based on your application’s needs:

EndpointMax attemptsWindowKey
Login1015 minIP
Login515 minEmail
Registration31 hourIP
Password reset515 minIP
Password reset315 minEmail
Email verification resend315 minUser ID
2FA verification55 minUser ID

It is good practice to include rate limit information in your response headers so clients know their limits:

function setRateLimitHeaders(res, result) {
res.setHeader('X-RateLimit-Remaining', result.remaining);
res.setHeader('X-RateLimit-Reset', result.resetAt.toISOString());
if (!result.allowed) {
res.setHeader(
'Retry-After',
Math.ceil((result.resetAt.getTime() - Date.now()) / 1000),
);
}
}
export async function login(req, res) {
const ip = getClientIp(req);
const result = await loginLimiter.attempt(`login:ip:${ip}`);
setRateLimitHeaders(res, result);
if (!result.allowed) {
return res.status(429).json({ error: 'Too many requests' });
}
// ... handle login
}