How to Implement Rate Limiting for Authentication
Stop brute force attacks before they start
TL;DR
TL;DR (20 minutes): Rate limit by both IP (10/15min) and email/username (5/15min). Use Redis with Upstash or similar. Lock accounts after 5 failed attempts for 15 minutes. Add progressive delays (1s, 2s, 4s...) between attempts. Always return generic errors to prevent enumeration.
Prerequisites:
- Authentication system to protect
- Redis or similar for state storage
- Understanding of your traffic patterns
Why This Matters
Without rate limiting, attackers can try thousands of password combinations per second. Credential stuffing attacks use leaked passwords from other sites - a single IP can attempt millions of logins targeting different accounts. Rate limiting is essential protection.
Step-by-Step Guide
Set up Upstash Redis
npm install @upstash/ratelimit @upstash/redis
// lib/redis.ts
import { Redis } from '@upstash/redis';
export const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN
});
Create rate limiters
import { Ratelimit } from '@upstash/ratelimit';
import { redis } from './redis';
// Global IP rate limit (loose - catches distributed attacks)
export const ipRateLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, '15 m'),
prefix: 'ratelimit:ip'
});
// Per-email rate limit (strict - protects individual accounts)
export const emailRateLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, '15 m'),
prefix: 'ratelimit:email'
});
// Failed attempt tracker (for progressive delays)
export const failedAttemptLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '1 h'),
prefix: 'ratelimit:failed'
});
Implement the rate limiting middleware
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: Date;
retryAfter?: number;
}
async function checkAuthRateLimits(
email: string,
ip: string
): Promise {
// Check IP limit
const ipResult = await ipRateLimiter.limit(ip);
if (!ipResult.success) {
return {
allowed: false,
remaining: 0,
resetAt: new Date(ipResult.reset),
retryAfter: Math.ceil((ipResult.reset - Date.now()) / 1000)
};
}
// Check email-specific limit
const emailResult = await emailRateLimiter.limit(email.toLowerCase());
if (!emailResult.success) {
return {
allowed: false,
remaining: 0,
resetAt: new Date(emailResult.reset),
retryAfter: Math.ceil((emailResult.reset - Date.now()) / 1000)
};
}
return {
allowed: true,
remaining: Math.min(ipResult.remaining, emailResult.remaining),
resetAt: new Date(Math.max(ipResult.reset, emailResult.reset))
};
}
Add progressive delays
async function getProgressiveDelay(email: string): Promise {
const key = `auth:delay:${email.toLowerCase()}`;
// Get current attempt count
const attempts = await redis.get(key) || 0;
// Exponential backoff: 0, 1, 2, 4, 8, 16... seconds (max 30)
const delay = Math.min(Math.pow(2, attempts - 1), 30) * 1000;
return attempts > 0 ? delay : 0;
}
async function recordFailedAttempt(email: string): Promise {
const key = `auth:delay:${email.toLowerCase()}`;
await redis.pipeline()
.incr(key)
.expire(key, 15 * 60) // Reset after 15 minutes
.exec();
}
async function clearFailedAttempts(email: string): Promise {
const key = `auth:delay:${email.toLowerCase()}`;
await redis.del(key);
}
// Apply delay before processing
async function applyProgressiveDelay(email: string): Promise {
const delay = await getProgressiveDelay(email);
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay));
}
}
Implement account lockout
const LOCKOUT_THRESHOLD = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
async function checkAccountLockout(email: string): Promise<{
locked: boolean;
unlocksAt?: Date;
}> {
const lockKey = `auth:locked:${email.toLowerCase()}`;
const lockedUntil = await redis.get(lockKey);
if (lockedUntil && lockedUntil > Date.now()) {
return {
locked: true,
unlocksAt: new Date(lockedUntil)
};
}
return { locked: false };
}
async function lockAccountIfNeeded(email: string): Promise {
const attemptKey = `auth:attempts:${email.toLowerCase()}`;
const lockKey = `auth:locked:${email.toLowerCase()}`;
// Increment failed attempts
const attempts = await redis.incr(attemptKey);
await redis.expire(attemptKey, 15 * 60);
if (attempts >= LOCKOUT_THRESHOLD) {
// Lock the account
const unlocksAt = Date.now() + LOCKOUT_DURATION;
await redis.set(lockKey, unlocksAt, { ex: Math.ceil(LOCKOUT_DURATION / 1000) });
// Clear attempt counter
await redis.del(attemptKey);
return true; // Account is now locked
}
return false;
}
async function unlockAccount(email: string): Promise {
const lockKey = `auth:locked:${email.toLowerCase()}`;
const attemptKey = `auth:attempts:${email.toLowerCase()}`;
await redis.del(lockKey);
await redis.del(attemptKey);
}
Complete login endpoint with rate limiting
async function login(req, res) {
const { email, password } = req.body;
const ip = req.ip || req.headers['x-forwarded-for'] || 'unknown';
// Step 1: Check rate limits
const rateLimit = await checkAuthRateLimits(email, ip);
if (!rateLimit.allowed) {
return res.status(429).json({
error: 'Too many login attempts. Please try again later.',
retryAfter: rateLimit.retryAfter
});
}
// Step 2: Check account lockout
const lockout = await checkAccountLockout(email);
if (lockout.locked) {
return res.status(423).json({
error: 'Account temporarily locked due to too many failed attempts.',
unlocksAt: lockout.unlocksAt
});
}
// Step 3: Apply progressive delay
await applyProgressiveDelay(email);
// Step 4: Verify credentials
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() }
});
// Generic error prevents enumeration
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
// Record failure
await recordFailedAttempt(email);
const locked = await lockAccountIfNeeded(email);
if (locked) {
return res.status(423).json({
error: 'Account temporarily locked due to too many failed attempts.',
unlocksAt: new Date(Date.now() + LOCKOUT_DURATION)
});
}
return res.status(401).json({
error: 'Invalid email or password.'
});
}
// Step 5: Success - clear failures and create session
await clearFailedAttempts(email);
const session = await createSession(user.id, req);
return res.json({
success: true,
user: { id: user.id, email: user.email }
});
}
Add rate limit headers
// Middleware to add rate limit headers
async function addRateLimitHeaders(req, res, next) {
const ip = req.ip;
const result = await ipRateLimiter.limit(ip);
// Standard rate limit headers
res.setHeader('X-RateLimit-Limit', result.limit);
res.setHeader('X-RateLimit-Remaining', result.remaining);
res.setHeader('X-RateLimit-Reset', result.reset);
if (!result.success) {
res.setHeader('Retry-After', Math.ceil((result.reset - Date.now()) / 1000));
return res.status(429).json({
error: 'Rate limit exceeded'
});
}
next();
}
Rate Limiting Best Practices:
- Rate limit by both IP and username/email - attackers can rotate IPs
- Use sliding window algorithm - more accurate than fixed windows
- Return 429 status code with Retry-After header
- Never reveal whether an email exists in error messages
- Consider CAPTCHA after a few failed attempts
- Monitor and alert on unusual rate limit triggers
- Whitelist your own services/IPs if needed
How to Verify It Worked
- Test IP limiting: Make 25+ requests from same IP - should be blocked
- Test email limiting: Try 6+ logins for same email - should be blocked
- Test lockout: Fail 5 logins - account should lock
- Test progressive delay: Each failure should take longer
- Test legitimate login: After delay expires, verify normal login works
Common Errors & Troubleshooting
Rate limiting not working consistently
Check Redis connection. In serverless, ensure you're using the same Redis instance across all function instances.
Legitimate users getting blocked
Your limits may be too strict. Monitor actual usage patterns and adjust. Consider higher limits for verified accounts.
Attackers bypassing IP limits
They're using rotating proxies. Implement per-email limits, CAPTCHA after failures, and consider device fingerprinting.
Redis connection errors
Add fallback behavior - if Redis is down, either fail open (allow login) or fail closed (deny all). Choose based on your security needs.
What limits should I set?
Start with: 20 requests/15min per IP, 5 requests/15min per email. Lock account after 5 failures for 15 minutes. Adjust based on your user behavior and security needs.
Should I use CAPTCHA instead?
Use both. Rate limiting is your first line of defense. Show CAPTCHA after 2-3 failed attempts. This balances security with UX.
How do I handle shared IP addresses (offices, universities)?
Keep per-IP limits reasonable (not too strict), rely more on per-email limits, and consider adding CAPTCHA. You can also whitelist known corporate IPs.
Related guides:Secure Login Form · General Rate Limiting · Password Reset Security