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

  1. Create a free Redis database at upstash.com
  2. Go to your database - REST API
  3. Copy UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN
  4. Add both to .env.local

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

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.