How to Implement Rate Limiting for Authentication

Share
How-To Guide

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

1

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
});
2

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'
});
3

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))
  };
}
4

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));
  }
}
5

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);
}
6

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 }
  });
}
7

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

  1. Test IP limiting: Make 25+ requests from same IP - should be blocked
  2. Test email limiting: Try 6+ logins for same email - should be blocked
  3. Test lockout: Fail 5 logins - account should lock
  4. Test progressive delay: Each failure should take longer
  5. 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

How-To Guides

How to Implement Rate Limiting for Authentication