Rate Limiting
Rate Limiting
Rate limiting is provided as a built-in plugin to protect your API from abuse and ensure fair usage. It registers as pre-auth middleware on the auto-api pipeline: every generated route and every createEndpoint route. Plain Nitro handlers (defineEventHandler) are not covered.
Installation
The rate limiting plugin is included with @websideproject/nuxt-auto-api. Enable it via the plugins array:
// nuxt.config.ts
import { createRateLimitPlugin } from '@websideproject/nuxt-auto-api/plugins'
export default defineNuxtConfig({
autoApi: {
plugins: [
createRateLimitPlugin({
windowMs: 60000, // 1 minute
max: 100, // 100 requests per minute
byIp: true,
byUser: false
})
]
}
})
No additional Nitro plugin or manual middleware setup is needed. The plugin automatically registers pre-auth middleware that checks rate limits before authorization runs.
Migration note: The legacy
extensions: [...]field is no longer read. ReplacecreateRateLimitExtensionwithcreateRateLimitPlugininplugins: [...]— the options are the same.
Configuration Options
interface RateLimitPluginOptions {
// Window size in milliseconds (default: 60000 = 1 minute)
windowMs?: number
// Maximum requests per window (default: 100)
max?: number
// Rate limit by the client's IP address (default: true) — see "Which address" below
byIp?: boolean
// Behind your own reverse proxy: how many proxies append to X-Forwarded-For (true = 1). Default: false
trustProxy?: boolean | number
// Rate limit by user ID (default: false)
byUser?: boolean
// Custom key generator (receives the HandlerContext)
keyGenerator?: (ctx: any) => string
// Skip rate limiting for certain requests (receives the HandlerContext)
skip?: (ctx: any) => boolean
// Custom error message
message?: string
// Counter-based backing store (default: InMemoryRateLimitStore). Gives full headers.
store?: RateLimitStore
// Decision-based limiter (e.g. Cloudflare RateLimit binding). Takes precedence over `store`.
// Pass the binding directly, or a resolver that reads it from the request context.
limiter?: RateLimitLimiter | ((ctx: any) => RateLimitLimiter | undefined)
// On store/limiter error, allow the request (fail open). `false` answers 429 instead. Default: true.
failOpen?: boolean
}
// Counter store — full X-RateLimit-* headers
interface RateLimitStore {
increment(key: string, windowMs: number): Promise<{ count: number, resetAt: number }>
reset?(key: string): Promise<void>
}
// Decision limiter — shape matches the Cloudflare Workers RateLimit binding exactly
interface RateLimitLimiter {
limit(args: { key: string }): Promise<{ success: boolean }>
}
Which address
byIp keys on an address a client cannot choose:
| Where the app runs | Address used |
|---|---|
| Cloudflare Workers / Pages (Nitro's Cloudflare presets) | CF-Connecting-IP — Cloudflare sets it and overwrites any value the client sent |
Behind your own proxy, with trustProxy: n | the X-Forwarded-For entry n hops from the right — the one your outermost proxy saw |
| Anything else | the connection's address |
X-Forwarded-For is never read without trustProxy: with no proxy in front, a client puts a new value in it on
every request and gets a fresh bucket each time. clientAddress(event, trustProxy) (from
@websideproject/nuxt-auto-api/plugins) is the same resolution, for your own keyGenerator.
The
keyGeneratorandskipcallbacks receive the HandlerContext (withctx.user,ctx.requestMeta,ctx.resource,ctx.event), not a raw H3 event. The middleware runspre-auth— after the auth/billing context extenders — so plan and user data are already available.
Cloudflare Workers (recommended)
The default in-memory store keeps counters in process memory. On Cloudflare Workers each request may hit a different isolate with its own memory, so a global "100/min" limit becomes "100 × N isolates" — effectively unenforced. Use one of the two distributed options.
Option A — native RateLimit binding (best)
Cloudflare has a first-class RateLimit API: configure the limit in wrangler.toml, and the binding handles distributed counting for you (no Durable Object). The limiter option's shape matches the binding exactly, so you pass it straight through.
# wrangler.toml
[[unsafe.bindings]]
name = "RATE_LIMITER"
type = "ratelimit"
namespace_id = "1"
simple = { limit = 100, period = 60 }
createRateLimitPlugin({
byUser: true, // bucket key per user
max: 100, windowMs: 60_000, // advisory — used for headers only
limiter: (ctx) => ctx.event.context.cloudflare?.env?.RATE_LIMITER,
})
The binding returns only { success }, so on this path the plugin sets X-RateLimit-Limit and (on 429) Retry-After from windowMs; precise Remaining/Reset are only available on the counter-store path.
Plan-aware limits (billing integration)
Because the middleware runs pre-auth — after the auth and billing context extenders — ctx.requestMeta.billing and ctx.user are already populated. Bind one limiter per tier and select by plan:
# wrangler.toml
[[ratelimits]]
name = "FREE_USER_RATE_LIMITER"
namespace_id = "1001"
simple = { limit = 100, period = 60 }
[[ratelimits]]
name = "PAID_USER_RATE_LIMITER"
namespace_id = "1002"
simple = { limit = 1000, period = 60 }
createRateLimitPlugin({
byUser: true,
limiter: (ctx) => {
const env = ctx.event.context.cloudflare?.env
const b = ctx.requestMeta?.billing // set by the billing-context plugin
const isPaid = b?.isActive || b?.isTrialing
return isPaid ? env?.PAID_USER_RATE_LIMITER : env?.FREE_USER_RATE_LIMITER
},
})
The actual numbers live in
wrangler.toml, so changing a tier's limit is a Worker config change. If you'd rather drive limits from your app/plan config without redeploying Workers, use the counter-store(KV) path below, wheremax/windowMscome from your code.
Option B — Cloudflare KV counter store
A counter store over KV gives cross-isolate counting and full X-RateLimit-* headers (KV is eventually consistent, ~60s):
class KvRateLimitStore implements RateLimitStore {
constructor(private kv: KVNamespace) {}
async increment(key: string, windowMs: number) {
const now = Date.now()
const raw = await this.kv.get(key, 'json') as { count: number, resetAt: number } | null
if (!raw || now > raw.resetAt) {
const fresh = { count: 1, resetAt: now + windowMs }
await this.kv.put(key, JSON.stringify(fresh), { expirationTtl: Math.ceil(windowMs / 1000) })
return fresh
}
raw.count++
await this.kv.put(key, JSON.stringify(raw), { expirationTtl: Math.ceil((raw.resetAt - now) / 1000) })
return raw
}
async reset(key: string) { await this.kv.delete(key) }
}
createRateLimitPlugin({
windowMs: 60_000, max: 100, byUser: true,
store: new KvRateLimitStore(env.RATE_LIMIT_KV),
})
(A store is an instance: register this one from a server file, not inline in nuxt.config — see below.)
Examples
Which address
byIp keys on an address a client cannot choose:
| Where the app runs | Address used |
|---|---|
| Cloudflare Workers / Pages (Nitro's Cloudflare presets) | CF-Connecting-IP — Cloudflare sets it and overwrites any value the client sent |
Behind your own proxy, with trustProxy: n | the X-Forwarded-For entry n hops from the right — the one your outermost proxy saw |
| Anything else | the connection's address |
X-Forwarded-For is never read without trustProxy: with no proxy in front, a client puts a new value in it on
every request and gets a fresh bucket each time. clientAddress(event, trustProxy) (from
@websideproject/nuxt-auto-api/plugins) is the same resolution, for your own keyGenerator.
The
keyGeneratorandskipcallbacks receive the HandlerContext (ctx.user,ctx.resource,ctx.requestMeta,ctx.event), since the plugin runs aspre-authmiddleware — not a raw H3 event.
Stricter limit for specific resources
createRateLimitPlugin applies one window globally; to vary by resource, branch inside skip/keyGenerator, or run two plugins and skip the inverse set. Example — a tight limit only on exports/auth, normal elsewhere:
autoApi: {
plugins: [
// Strict: only the sensitive resources, low max
createRateLimitPlugin({
windowMs: 60_000, max: 5, byIp: true,
skip: (ctx) => !['exports', 'authTokens'].includes(ctx.resource),
}),
// Normal: everything else
createRateLimitPlugin({
windowMs: 60_000, max: 100, byIp: true,
skip: (ctx) => ['exports', 'authTokens'].includes(ctx.resource),
}),
],
}
For genuinely different numeric limits per route on Cloudflare, bind one RateLimit binding per route/tier and select it in
limiter(see Cloudflare Workers) — the limits live inwrangler.toml.
Rate Limit by User
createRateLimitPlugin({
windowMs: 60000,
max: 100,
byIp: false,
byUser: true // Rate limit per authenticated user (keys by user:id)
})
Combined IP and User
createRateLimitPlugin({
windowMs: 60000,
max: 100,
byIp: true,
byUser: true // Key combines both — same user from a new IP gets a fresh bucket
})
Custom Key Generator
createRateLimitPlugin({
windowMs: 60000,
max: 100,
keyGenerator: (ctx) => {
// Rate limit by org so a whole workspace shares one bucket
return `org:${ctx.user?.organizationId ?? 'anon'}`
}
})
Skip Certain Requests
createRateLimitPlugin({
windowMs: 60000,
max: 100,
skip: (ctx) => {
// Don't rate limit global admins or internal/cron calls
return !!ctx.user?.roles?.includes('admin')
}
})
Custom Error Message
createRateLimitPlugin({
windowMs: 60000,
max: 100,
message: 'You have exceeded the request limit. Please try again in a minute.'
})
Response Headers
Rate limit information is included in response headers:
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1704067200000
When limit is exceeded:
HTTP/1.1 429 Too Many Requests
Retry-After: 42
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1704067200000
{
"statusCode": 429,
"statusMessage": "Too Many Requests",
"message": "Too many requests, please try again later"
}
Retry-After (seconds) is always set on 429. X-RateLimit-Remaining/Reset are precise on the counter-store path; on the decision-limiter path (CF binding) only Limit + Retry-After are set.
Storage Backends
In-Memory (Default)
Suitable for single-server deployments:
createRateLimitPlugin({
// Uses in-memory store by default
})
Pros:
- Fast
- No external dependencies
- Automatic cleanup
Cons:
- Not shared across multiple servers
- Lost on server restart
- Limited to server memory
Cloudflare (Workers)
See Cloudflare Workers above — the native RateLimit binding (limiter) or a KV-backed counter store.
Redis (multi-server, non-Workers)
Implement the RateLimitStore interface — increment(key, windowMs) must return { count, resetAt }:
// server/utils/redis-rate-limit-store.ts
import { Redis } from 'ioredis'
import type { RateLimitStore } from '@websideproject/nuxt-auto-api/plugins'
const redis = new Redis(process.env.REDIS_URL)
export class RedisRateLimitStore implements RateLimitStore {
async increment(key: string, windowMs: number) {
const count = await redis.incr(key)
if (count === 1) {
await redis.pexpire(key, windowMs) // set window TTL on first hit
}
const pttl = await redis.pttl(key)
return { count, resetAt: Date.now() + Math.max(0, pttl) }
}
async reset(key: string) {
await redis.del(key)
}
}
A store is an instance, so register this plugin from a server file — an instance listed in nuxt.config cannot
reach the server and fails the build (see Plugin Registration):
// nuxt.config.ts
export default defineNuxtConfig({
autoApi: { plugins: '~/server/autoapi-plugins' },
})
// server/autoapi-plugins.ts
import { createRateLimitPlugin } from '@websideproject/nuxt-auto-api/plugins'
import { RedisRateLimitStore } from './utils/redis-rate-limit-store'
export default [
createRateLimitPlugin({ windowMs: 60_000, max: 100, store: new RedisRateLimitStore() }),
]
Pros:
- Shared across multiple servers
- Persists across restarts
- Full
X-RateLimit-*headers
Cons:
- Requires Redis server (not available on Cloudflare Workers — use KV/binding there)
Advanced Patterns
createRateLimitPlugin registers pre-auth middleware with a single window. To vary behavior, register multiple plugins (each runs in turn) and use skip/keyGenerator/limiter — all of which receive the full HandlerContext.
Different Limits for Different Plans
The plugin's max is fixed per registration, so vary it by registering one plugin per tier and skipping the others. ctx.requestMeta.billing is populated (the billing-context plugin runs before this middleware):
const isPaid = (ctx: any) => ctx.requestMeta?.billing?.isActive || ctx.requestMeta?.billing?.isTrialing
autoApi: {
plugins: [
createRateLimitPlugin({ // paid: 1000/min
windowMs: 60_000, max: 1000, byUser: true,
skip: (ctx) => !isPaid(ctx),
}),
createRateLimitPlugin({ // free/anon: 100/min
windowMs: 60_000, max: 100, byUser: true,
skip: (ctx) => isPaid(ctx),
}),
],
}
On Cloudflare, prefer one RateLimit binding per tier selected in
limiter(limits inwrangler.toml) — see Cloudflare Workers. That avoids running two middlewares.
Burst + Sustained
Register two plugins with different windows and namespaced keys — both must pass:
autoApi: {
plugins: [
createRateLimitPlugin({ windowMs: 10_000, max: 20, byIp: true, keyGenerator: (ctx) => `burst:${ctx.user?.id ?? clientAddress(ctx.event)}` }),
createRateLimitPlugin({ windowMs: 60_000, max: 100, byIp: true, keyGenerator: (ctx) => `sustained:${ctx.user?.id ?? clientAddress(ctx.event)}` }),
],
}
// import { clientAddress } from '@websideproject/nuxt-auto-api/plugins' — the plugin's own address resolution
Usage-based / progressive limits
Bake the decision into a custom store (or limiter) — it owns counting, so it can read a per-key allowance. E.g. a store whose increment looks up the user's tier allowance and reports count/resetAt accordingly, or a limiter that calls different CF bindings by recent usage. Keep the dynamic logic in the backend, not in the plugin options.
Monitoring
The plugin throws a 429 when over limit. Observe hits without re-implementing counting by wrapping your store/limiter, or via a response-status hook.
Wrap the store/limiter (plugin-native)
class ObservedLimiter implements RateLimitLimiter {
constructor(private inner: RateLimitLimiter, private onBlock: (key: string) => void) {}
async limit(args: { key: string }) {
const r = await this.inner.limit(args)
if (!r.success) this.onBlock(args.key)
return r
}
}
createRateLimitPlugin({
limiter: (ctx) => new ObservedLimiter(
ctx.event.context.cloudflare.env.RATE_LIMITER,
(key) => console.warn('rate-limit blocked', { key, resource: ctx.resource, userId: ctx.user?.id }),
),
})
Response-status hook (counts 429s)
// server/plugins/rate-limit-metrics.ts
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('afterResponse', (event) => {
if (getResponseStatus(event) === 429) {
// increment a counter / emit an event — e.g. Prometheus, Umami, or the events module
console.warn('rate-limit 429', { path: event.path, userId: event.context.user?.id })
}
})
})
Testing
Disable in Development
createRateLimitPlugin({
skip: () => process.env.NODE_ENV === 'development', // `skip` receives the HandlerContext
})
Test Rate Limiting
Through the real routes, with a fixture app that lists the plugin (max: 100) — see Testing:
// test/rate-limiting.test.ts
import { fileURLToPath } from 'node:url'
import { describe, it, expect } from 'vitest'
import { setup, fetch } from '@nuxt/test-utils/e2e'
describe('rate limiting', async () => {
await setup({ rootDir: fileURLToPath(new URL('./fixtures/api', import.meta.url)) })
it('answers 429 after the limit', async () => {
for (let i = 0; i < 100; i++) await fetch('/api/posts') // sequential: each one is counted in order
const res = await fetch('/api/posts')
expect(res.status).toBe(429)
expect(res.headers.get('Retry-After')).toBeTruthy()
})
})
Client-Side Handling
Respect Rate Limits
// app/utils/fetchWithRateLimit.ts
export async function fetchWithRateLimit<T>(url: string, options: Parameters<typeof $fetch>[1] = {}): Promise<T> {
try {
return await $fetch<T>(url, options)
}
catch (error: any) {
if (error?.statusCode !== 429) throw error
// The server sets `Retry-After` (seconds) on every 429
const retryAfter = Number(error.response?.headers.get('Retry-After')) || 60
console.warn(`Rate limited. Retrying in ${retryAfter} seconds...`)
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000))
return await $fetch<T>(url, options)
}
}
Display Rate Limit Info
$fetch.raw returns the response with its headers:
<script setup lang="ts">
const rateLimitInfo = ref<{ limit: string | null, remaining: string | null, reset: Date } | null>(null)
onMounted(async () => {
const response = await $fetch.raw('/api/users')
rateLimitInfo.value = {
limit: response.headers.get('X-RateLimit-Limit'),
remaining: response.headers.get('X-RateLimit-Remaining'),
reset: new Date(Number(response.headers.get('X-RateLimit-Reset'))),
}
})
</script>
<template>
<div v-if="rateLimitInfo">
{{ rateLimitInfo.remaining }} / {{ rateLimitInfo.limit }} requests remaining
(resets at {{ rateLimitInfo.reset.toLocaleTimeString() }})
</div>
</template>
X-RateLimit-Remaining / -Reset are only sent on the counter-store path (see above).
Best Practices
- Start conservative: Begin with lower limits and increase based on usage
- Monitor closely: Track 429 errors to find the right limits
- Use a shared backend in production: in-memory leaks across isolates/servers — Cloudflare RateLimit binding or KV on Workers, Redis elsewhere
- Differentiate users: paid tiers get higher limits (select by
ctx.requestMeta.billing) - Protect expensive endpoints: Lower limits for resource-intensive operations
- Clear error messages: Help users understand what happened
- Provide headers: Include X-RateLimit-* headers
- Test thoroughly: Ensure rate limiting doesn't break normal usage
- Document limits: Tell users what the limits are
- Consider quotas: Combine with daily/monthly quotas for paid tiers
Troubleshooting
Rate Limit Not Working
Check that:
createRateLimitPlugin(...)is inautoApi.pluginsin nuxt.config.ts- Your
skipfunction isn't excluding all requests - The shared store/limiter (KV namespace / CF binding / Redis) is configured and reachable
- On Cloudflare with the default in-memory store, limits won't hold across isolates — use the binding or a KV store
Too Many False Positives
Adjust limits:
createRateLimitPlugin({
windowMs: 60000,
max: 200 // Increase limit
})
Or use per-user limits instead of per-IP:
createRateLimitPlugin({
byIp: false,
byUser: true
})
Memory Issues
Switch to a shared store:
- In-memory store grows with traffic and is per-isolate on Workers
- Cloudflare KV / RateLimit binding (Workers) or Redis (servers) provide bounded, shared state
Alternatives
- Cloudflare: Rate limiting at the edge
- nginx: Rate limiting at reverse proxy
- API Gateway: AWS API Gateway, Kong, etc.
- Custom middleware: Full control but more work
Migration Path
From custom rate limiting:
// Before: Custom implementation
const requests = new Map()
export default defineEventHandler(async (event) => {
const ip = getRequestIP(event)
const count = requests.get(ip) || 0
if (count > 100) {
throw createError({ statusCode: 429, message: 'Too many requests' })
}
requests.set(ip, count + 1)
// ... rest of handler
})
// After: Use the plugin
createRateLimitPlugin({
max: 100,
byIp: true
})
Benefits:
- Automatic window management
- Response headers
- Multiple storage backends
- Better error messages
- Configuration options
Validation
@websideproject/nuxt-auto-api uses Zod for validation with automatic schema generation from Drizzle tables via drizzle-zod.
Request Metadata Plugin
The Request Metadata Plugin automatically captures request metadata (IP address, geolocation, user-agent, etc.) and makes it available throughout your API handlers. It supports both context-only access (for hooks and authorization) and optional database persistence.