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:
- IP-only limit (20/15min): Catches attackers trying different emails from same IP (credential stuffing)
- 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