Authentication Rate Limiting

Protecting authentication endpoints is critical. This example shows how to implement layered rate limiting that prevents both credential stuffing and account takeover attacks.

Scenario

  • Login: 5 attempts per 15 minutes (per email + IP)
  • Login (IP-only): 20 attempts per 15 minutes (catches distributed attacks)
  • Registration: 3 accounts per hour (per IP)
  • Password reset: 3 requests per hour (prevents enumeration)
  • 2FA verification: 5 attempts per 15 minutes (with lockout)

Implementation

src/middleware/auth-rate-limits.ts
import { hitlimit } from '@joint-ops/hitlimit'
import { redisStore } from '@joint-ops/hitlimit/stores/redis'

const redis = redisStore({
  url: process.env.REDIS_URL,
  keyPrefix: 'auth:ratelimit:'
})

// Login - strict per email AND per IP
export const loginLimit = hitlimit({
  store: redis,
  limit: 5,
  window: '15m',
  key: (req) => {
    const email = req.body.email?.toLowerCase()
    const ip = req.ip
    // Rate limit both independently
    // This prevents credential stuffing (by IP) AND account takeover (by email)
    return `login:${email}:${ip}`
  },
  response: (info) => ({
    error: 'LOGIN_RATE_LIMITED',
    message: `Too many login attempts. Please try again in ${Math.ceil(info.resetIn / 60)} minutes.`,
    resetIn: info.resetIn
  }),
  onStoreError: () => 'deny' // Security-critical, always fail closed
})

// Additional IP-only rate limit (catches distributed attacks)
export const loginIpLimit = hitlimit({
  store: redis,
  limit: 20,
  window: '15m',
  key: (req) => `login:ip:${req.ip}`,
  response: () => ({
    error: 'IP_RATE_LIMITED',
    message: 'Too many requests from your IP address.'
  }),
  onStoreError: () => 'deny'
})

// Registration - moderate (prevent spam accounts)
export const registrationLimit = hitlimit({
  store: redis,
  limit: 3,
  window: '1h',
  key: (req) => req.ip,
  response: () => ({
    error: 'REGISTRATION_LIMIT',
    message: 'Too many registration attempts. Please try again later.'
  }),
  onStoreError: () => 'deny'
})

// Password reset request - strict (prevent enumeration + spam)
export const passwordResetLimit = hitlimit({
  store: redis,
  limit: 3,
  window: '1h',
  key: (req) => {
    const email = req.body.email?.toLowerCase()
    return `password-reset:${email}:${req.ip}`
  },
  response: () => ({
    // Intentionally vague to prevent enumeration
    message: 'If an account exists with that email, a reset link has been sent.'
  }),
  onStoreError: () => 'deny'
})

// 2FA verification - strict with lockout
export const twoFactorLimit = hitlimit({
  store: redis,
  limit: 5,
  window: '15m',
  key: (req) => {
    const userId = req.session?.userId
    return `2fa:${userId}:${req.ip}`
  },
  response: (info) => ({
    error: '2FA_RATE_LIMITED',
    message: 'Too many verification attempts. Your account has been temporarily locked.',
    lockoutMinutes: Math.ceil(info.resetIn / 60)
  }),
  onStoreError: () => 'deny'
})

// Token refresh - moderate (legitimate users refresh often)
export const tokenRefreshLimit = hitlimit({
  store: redis,
  limit: 30,
  window: '1m',
  key: (req) => req.user?.id || req.ip
})

Usage in Routes

src/routes/auth.ts
import { Router } from 'express'
import * as limits from '../middleware/auth-rate-limits'

const router = Router()

// Login (very strict - check IP first, then email+IP)
router.post('/login',
  limits.loginIpLimit,  // Check IP first
  limits.loginLimit,    // Then check email+IP
  authController.login
)

// Registration (strict)
router.post('/register', limits.registrationLimit, authController.register)

// Password reset (strict)
router.post('/password/forgot', limits.passwordResetLimit, authController.forgotPassword)
router.post('/password/reset', limits.passwordResetLimit, authController.resetPassword)

// 2FA (strict)
router.post('/2fa/verify', limits.twoFactorLimit, authController.verify2FA)
router.post('/2fa/setup', limits.twoFactorLimit, authController.setup2FA)

// Token refresh (moderate)
router.post('/token/refresh', limits.tokenRefreshLimit, authController.refreshToken)

export default router

Security Considerations

Always Fail Closed for Auth

Authentication endpoints should always use onStoreError: () => 'deny'. If your rate limit store is down, it's safer to reject logins than to allow unlimited attempts.

Layered Protection

The login endpoint uses two rate limiters:

  1. IP-only limit (20/15min): Catches attackers trying different emails from same IP (credential stuffing)
  2. Email+IP limit (5/15min): Catches attackers targeting specific accounts (account takeover)

This layered approach provides protection against multiple attack vectors.

Vague Error Messages

For password reset, the response is intentionally vague: "If an account exists..." This prevents attackers from using rate limiting behavior to enumerate valid emails.

Why These Limits?

  • 5 login attempts: Generous for typos, strict against brute force
  • 15 minute window: Long enough to deter automated attacks
  • 20 IP limit: Catches distributed attacks without blocking office networks
  • 3 registrations/hour: Prevents spam accounts while allowing legitimate sign-ups
  • 5 2FA attempts: Allows human error but prevents brute forcing 6-digit codes