How to Set Up Rate Limiting in Nuxt

How to Set Up Rate Limiting in Nuxt

如何在 Nuxt 中设置速率限制 (Rate Limiting)

Rate limiting is one of those things that doesn’t feel urgent—until someone hammers your login endpoint at 3am and you wake up to a flooded database and a locked-out user base. I added this to my Nuxt base layer after realising I’d shipped several projects with zero protection on auth routes. Not great. This post walks through the exact setup I now use: Redis-backed, an in-memory fallback when Redis is down, named presets for different sensitivity levels, and a 429 page that shows a live countdown instead of just dying on the user.

速率限制属于那种平时感觉不到紧迫性的功能——直到凌晨三点有人疯狂攻击你的登录接口,你醒来发现数据库被挤爆,用户也无法登录。在意识到我发布的几个项目在身份验证路由上完全没有保护后,我将此功能添加到了我的 Nuxt 基础层中。这可不是什么好事。本文将详细介绍我现在使用的方案:基于 Redis 实现,在 Redis 宕机时提供内存回退,针对不同敏感度级别设置命名预设,以及一个显示实时倒计时的 429 页面,而不是直接向用户报错。

The structure

结构

Three pieces, each with one job:

  • createRateLimiter() — a factory that builds the limiter, using Redis with an in-memory fallback
  • applyRateLimit() — what you call inside handlers to enforce a limit
  • server/middleware/rateLimiter.ts — global middleware so every route gets a baseline for free

三个部分,各司其职:

  • createRateLimiter() — 一个构建限制器的工厂函数,使用 Redis 并提供内存回退。
  • applyRateLimit() — 在处理程序内部调用以强制执行限制。
  • server/middleware/rateLimiter.ts — 全局中间件,确保每个路由都能获得基础保护。

1. Install

1. 安装

npm install rate-limiter-flexible ioredis

rate-limiter-flexible does the heavy lifting: sliding windows, Redis integration, and the insurance fallback pattern we’ll use.

rate-limiter-flexible 承担了繁重的工作:滑动窗口、Redis 集成以及我们将要使用的保险回退模式。

2. The factory

2. 工厂函数

Create server/utils/rateLimiter.ts:

创建 server/utils/rateLimiter.ts

import { RateLimiterRedis, RateLimiterMemory, type RateLimiterAbstract, } from 'rate-limiter-flexible'
import { getRedisClient } from './redis'

export interface RateLimiterConfig {
  keyPrefix: string // Must be unique per limiter, e.g. 'rl:auth'
  limit: number // Maximum requests within the window
  windowSeconds: number
}

export interface RateLimitResult {
  allowed: boolean
  limit: number
  remaining: number
  resetAt: number // Unix timestamp in seconds when the window resets
  retryAfter: number // Seconds until retry; 0 if allowed
}

function buildLimiter(config: RateLimiterConfig): RateLimiterAbstract {
  const insurance = new RateLimiterMemory({
    keyPrefix: config.keyPrefix,
    points: config.limit,
    duration: config.windowSeconds,
  })

  const redis = getRedisClient()
  if (!redis) { return insurance }

  return new RateLimiterRedis({
    storeClient: redis,
    keyPrefix: config.keyPrefix,
    points: config.limit,
    duration: config.windowSeconds,
    insuranceLimiter: insurance, // Falls back to memory if Redis goes down
  })
}

export function createRateLimiter(config: RateLimiterConfig) {
  let limiter: RateLimiterAbstract | null = null

  function getLimiter(): RateLimiterAbstract {
    if (!limiter) { limiter = buildLimiter(config) }
    return limiter
  }

  return async function check(key: string): Promise<RateLimitResult> {
    try {
      const res = await getLimiter().consume(key)
      return {
        allowed: true,
        limit: config.limit,
        remaining: res.remainingPoints ?? 0,
        resetAt: Math.ceil(Date.now() / 1000) + Math.ceil((res.msBeforeNext ?? 0) / 1000),
        retryAfter: 0,
      }
    } catch (thrown: unknown) {
      // rate-limiter-flexible throws a RateLimiterRes object, not an Error, when the limit is exceeded.
      // If it throws something else, fail open. A broken limiter should not block every user.
      const res = thrown as Record<string, unknown>
      if (typeof res?.msBeforeNext !== 'number') {
        console.error('[rate-limiter] unexpected error:', thrown)
        return { allowed: true, limit: config.limit, remaining: 0, resetAt: 0, retryAfter: 0, }
      }
      const retryAfter = Math.ceil(res.msBeforeNext / 1000)
      return {
        allowed: false,
        limit: config.limit,
        remaining: 0,
        resetAt: Math.ceil(Date.now() / 1000) + retryAfter,
        retryAfter,
      }
    }
  }
}

Two things I want to highlight here:

  • Lazy initialization: the limiter builds itself on the first request, not at import time. This avoids initialization-order problems in environments where configuration or services may not be ready when modules are first loaded.
  • Fail open: when Redis throws something unexpected, the request goes through. I would rather have a temporarily unprotected endpoint than have a limiter bug take down the whole application for every user. For an especially sensitive system, you may decide to fail closed instead.

这里有两点我想强调:

  • 懒加载初始化:限制器在第一次请求时构建,而不是在导入时。这避免了在模块首次加载时配置或服务尚未就绪而导致的初始化顺序问题。
  • 故障开放 (Fail open):当 Redis 抛出意外错误时,请求依然会被放行。我宁愿端点暂时失去保护,也不愿让限制器的 Bug 导致整个应用程序对所有用户瘫痪。对于特别敏感的系统,你可以选择“故障关闭 (Fail closed)”。

3. Presets

3. 预设

Not all routes deserve the same treatment. A page view and a password-reset request are very different risks. Add named presets at the bottom of the same file:

并非所有路由都应受到同等对待。页面浏览和密码重置请求的风险截然不同。在同一文件的底部添加命名预设:

function env(name: string, fallback: number): number {
  const value = process.env[name]
  const parsed = value ? Number.parseInt(value, 10) : Number.NaN
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
}

// 60 requests per minute — general API traffic
export const apiRateLimiter = createRateLimiter({
  keyPrefix: 'rl:api',
  limit: env('NUXT_RATE_LIMITER_API_LIMIT', 60),
  windowSeconds: env('NUXT_RATE_LIMITER_API_WINDOW', 60),
})

// 10 requests per 15 minutes — login, register, OTP
export const authRateLimiter = createRateLimiter({
  keyPrefix: 'rl:auth',
  limit: env('NUXT_RATE_LIMITER_AUTH_LIMIT', 10),
  windowSeconds: env('NUXT_RATE_LIMITER_AUTH_WINDOW', 15 * 60),
})

// 5 requests per hour — password reset, email verification
export const sensitiveRateLimiter = createRateLimiter({
  keyPrefix: 'rl:sensitive',
  limit: env('NUXT_RATE_LIMITER_SENSITIVE_LIMIT', 5),
  windowSeconds: env('NUXT_RATE_LIMITER_SENSITIVE_WINDOW', 60 * 60),
})

// 200 requests per minute — SSR page routes
export const pageRateLimiter = createRateLimiter({
  keyPrefix: 'rl:page',
  limit: env('NUXT_RATE_LIMITER_PAGE_LIMIT', 200),
  windowSeconds: env('NUXT_RATE_LIMITER_PAGE_WINDOW', 60),
})

All limits are overridable through environment variables. You do not need to change the application code to tighten them in production.

所有限制都可以通过环境变量覆盖。你无需更改应用程序代码即可在生产环境中收紧限制。

4. The applyRateLimit() helper

4. applyRateLimit() 辅助函数

Create server/utils/applyRateLimit.ts:

创建 server/utils/applyRateLimit.ts

import type { H3Event } from 'h3'
import type { RateLimitResult } from './rateLimiter'

type LimiterFunction = (key: string) => Promise<RateLimitResult>

export function getClientIp(event: H3Event): string {
  return (
    getRequestHeader(event, 'cf-connecting-ip') ||
    getRequestHeader(event, 'x-real-ip') ||
    getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim() ||
    'unknown'
  )
}

function setRateLimitHeaders(event: H3Event, result: RateLimitResult): void {
  setResponseHeader(event, 'X-RateLimit-Limit', String(result.limit))
  setResponseHeader(event, 'X-RateLimit-Remaining', String(result.remaining))
}