Rate limiting
Upstash Redis sliding window on all auth and OTP routes. Graceful no-op fallback keeps the app running if Redis is unavailable.
Setup
- Create a free Redis database at upstash.com
- Go to your database - REST API
- Copy UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN
- Add both to .env.local
Upstash's free tier includes 10,000 commands per day - more than enough for a development environment and small production workloads.
The two limiters
lib/ratelimit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
// 5 requests per 15 minutes - login, register
export const authRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, '15 m'),
prefix: 'nextforge:auth',
})
// 3 requests per 10 minutes - OTP send, OTP resend
export const otpRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(3, '10 m'),
prefix: 'nextforge:otp',
})- authRatelimit - 5 requests per 15 minutes per identifier (email address). Used in loginAction and registerAction.
- otpRatelimit - 3 requests per 10 minutes per identifier. Used in sendPasswordResetOTPAction and resendVerificationOTPAction.
checkRateLimit helper
lib/ratelimit.ts
export async function checkRateLimit(
limiter: Ratelimit,
identifier: string
): Promise<ActionState<void>> {
try {
const { success } = await limiter.limit(identifier)
if (!success) {
return {
success: false,
error: 'Too many attempts. Please try again later.',
}
}
return { success: true }
} catch {
// Fail open - never block users due to Redis infrastructure issues
console.warn('Rate limit check failed - Redis may be unavailable')
return { success: true }
}
}Fail open by design
If Redis is unreachable, checkRateLimit returns success: true. This is intentional - rate limiting is a defense against abuse, not a hard gate. An unavailable Redis should not block legitimate users from logging in. The no-op fallback handles the same case.
No-op fallback
If UPSTASH_REDIS_REST_URL or UPSTASH_REDIS_REST_TOKEN are not set, ratelimit.ts exports a no-op limiter that always returns success: true and logs a one-time warning:
lib/ratelimit.ts
// No-op used when Redis env vars are missing
const noopLimiter = {
limit: async () => ({ success: true }),
}
let warned = false
// Returns the real limiter if env vars exist, no-op otherwise
function getRateLimiter() {
if (!process.env.UPSTASH_REDIS_REST_URL) {
if (!warned) {
console.warn('[NextForge] Rate limiting disabled - UPSTASH_REDIS_REST_URL not set')
warned = true
}
return noopLimiter
}
return redis // real limiter
}Customising limits
lib/ratelimit.ts
// More permissive auth limit
export const authRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '15 m'), // 10 per 15 minutes
prefix: 'nextforge:auth',
})
// Stricter OTP limit
export const otpRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(2, '15 m'), // 2 per 15 minutes
prefix: 'nextforge:otp',
})Sliding window is recommended over fixed window - it prevents the burst attack that's possible at fixed window boundaries.