Authentication Rate Limiting (Bun)
Protecting authentication endpoints is critical. This example shows how to implement layered rate limiting on Bun that prevents credential stuffing, brute force attacks, and account takeover attempts.
Scenario
- Login: 5 attempts per 15 minutes (per email + IP)
- Registration: 3 accounts per hour (per IP)
- Password reset: 3 requests per hour (prevents enumeration)
Implementation
src/rate-limits/auth.ts
import { hitlimit } from '@joint-ops/hitlimit-bun'
import { redisStore } from '@joint-ops/hitlimit-bun/stores/redis'
const redis = redisStore({
url: Bun.env.REDIS_URL,
keyPrefix: 'auth:rl:'
})
// Login - strict limits per email + IP
export const loginLimit = hitlimit({
store: redis,
limit: 5,
window: '15m',
key: async (req) => {
const body = await req.clone().json()
const email = body.email?.toLowerCase() ?? ''
const ip = req.headers.get('x-forwarded-for') ?? 'unknown'
return `login:${email}:${ip}`
},
response: (info) => ({
error: 'LOGIN_RATE_LIMITED',
message: `Too many login attempts. Try again in ${Math.ceil(info.resetIn / 60)} minutes.`,
resetIn: info.resetIn
}),
onStoreError: () => 'deny' // Security-critical: always fail closed
})
// Registration - prevent spam accounts
export const registrationLimit = hitlimit({
store: redis,
limit: 3,
window: '1h',
key: (req) => {
const ip = req.headers.get('x-forwarded-for') ?? 'unknown'
return `register:${ip}`
},
response: () => ({
error: 'REGISTRATION_LIMIT',
message: 'Too many registration attempts. Please try again later.'
}),
onStoreError: () => 'deny'
})
// Password reset - strict (prevent enumeration + spam)
export const passwordResetLimit = hitlimit({
store: redis,
limit: 3,
window: '1h',
key: async (req) => {
const body = await req.clone().json()
const email = body.email?.toLowerCase() ?? ''
const ip = req.headers.get('x-forwarded-for') ?? 'unknown'
return `password-reset:${email}:${ip}`
},
response: () => ({
// Intentionally vague to prevent email enumeration
message: 'If an account exists with that email, a reset link has been sent.'
}),
onStoreError: () => 'deny'
}) Route Handling
Apply the rate limiters inside a Bun.serve fetch handler, routing by URL pathname:
src/server.ts
import { loginLimit, registrationLimit, passwordResetLimit } from './rate-limits/auth'
Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url)
// POST /auth/login
if (url.pathname === '/auth/login' && req.method === 'POST') {
const limited = await loginLimit(req)
if (limited) return limited
// ... handle login
return Response.json({ ok: true })
}
// POST /auth/register
if (url.pathname === '/auth/register' && req.method === 'POST') {
const limited = await registrationLimit(req)
if (limited) return limited
// ... handle registration
return Response.json({ ok: true })
}
// POST /auth/password/forgot
if (url.pathname === '/auth/password/forgot' && req.method === 'POST') {
const limited = await passwordResetLimit(req)
if (limited) return limited
// ... handle password reset
return Response.json({ ok: true })
}
return new Response('Not Found', { status: 404 })
}
}) Key Points
- Always fail closed for auth: Every limiter uses
onStoreError: () => 'deny'. If your Redis store is unavailable, it is safer to reject requests than to allow unlimited login attempts. - Clone the request before reading the body:
req.clone().json()ensures the original request body remains available for your route handler. - Combine email + IP keys for login: This prevents both credential stuffing (many emails from one IP) and targeted brute force (many IPs targeting one email).
- Use vague password reset messages: A generic "If an account exists..." response prevents attackers from using the endpoint to enumerate valid email addresses.
- Use
Bun.envfor configuration: Access environment variables natively throughBun.env.REDIS_URLinstead ofprocess.env. - Separate key prefixes per endpoint: Each limiter targets a specific action (login, register, password-reset), so one endpoint being rate limited does not affect the others.