Rate limiting
ShipVeryFast ships with five named rate limiters built on rate-limiter-flexible. They live in libs/rateLimiter.ts and are wired into middleware.ts (path-based, per-IP) and into individual routes (per-user). This page explains each budget, how a limiter is selected, and how to move from the in-memory store to Redis for multi-instance deployments.
The configured rate limiters
Every limiter is a RateLimiterMemory instance exported from libs/rateLimiter.ts. A limiter is defined by points (how many requests are allowed) and duration (the window, in seconds). The magic-link limiter reads its budget from environment variables; the rest are hard-coded.
// libs/rateLimiter.ts
import { RateLimiterMemory } from 'rate-limiter-flexible';
import { env } from './config';
// Magic Link requests, budget comes from env
export const magicLinkRateLimiter = new RateLimiterMemory({
points: env.RATE_LIMIT_MAGICLINK_MAX,
duration: env.RATE_LIMIT_MAGICLINK_DURATION,
});
// API endpoints
export const apiRateLimiter = new RateLimiterMemory({
points: 60, // 60 requests
duration: 60, // per 60 seconds
});
// Authentication endpoints (login, signup)
export const authRateLimiter = new RateLimiterMemory({
points: 10,
duration: 60,
});
// Sensitive operations (password change, email change, subscription)
export const sensitiveOpRateLimiter = new RateLimiterMemory({
points: 5,
duration: 60,
});
// AI chat requests (LLM calls are expensive)
export const aiRateLimiter = new RateLimiterMemory({
points: 20,
duration: 60,
});| Limiter | Budget | Key | Used by |
|---|---|---|---|
magicLinkRateLimiter | env-driven (default 5 / 3600s) | email identifier | libs/auth.ts (EmailProvider) |
apiRateLimiter | 60 / 60s | client IP | middleware.ts (general fallback) |
authRateLimiter | 10 / 60s | client IP | middleware.ts (/api/auth) |
sensitiveOpRateLimiter | 5 / 60s | client IP | middleware.ts (password / email / subscription) |
aiRateLimiter | 20 / 60s | session email | app/api/ai/chat/route.ts |
How middleware picks a limiter by path
The default export of middleware.ts wraps everything in NextAuth's withAuth. For any request under /api/ it calls a local advancedRateLimiter(req) function, which inspects req.nextUrl.pathname and consumes from the matching limiter. The first matching branch wins, so the order below is the order in code:
// middleware.ts, advancedRateLimiter(req)
const path = req.nextUrl.pathname;
if (path.startsWith('/api/auth')) {
await authRateLimiter.consume(ip); // 10 / 60s
} else if (
path.startsWith('/api/user/password') ||
path.startsWith('/api/user/email') ||
path.startsWith('/api/subscription')
) {
await sensitiveOpRateLimiter.consume(ip); // 5 / 60s
} else {
await apiRateLimiter.consume(ip); // 60 / 60s (general fallback)
}The middleware only runs on the routes listed in its exported config.matcher, which includes /api/:path*, so every API route is covered by the per-IP rate limiter. The AI chat route is also under /api/, so it passes through the general apiRateLimiter in middleware and the tighter aiRateLimiter inside the route handler.
Order matters
Because the checks are an if / else if chain, a sensitive route under /api/auth would match the auth branch first. Keep new sensitive paths out of the /api/auth prefix (or add them to the sensitive-op branch) so they get the budget you expect.
Per-user vs per-IP keys
Each call to limiter.consume(key)tracks a separate budget for that key. The choice of key decides what "one client" means.
Per-IP (middleware)
The middleware derives the IP from the x-forwarded-for header, taking the first entry, and falls back to 'unknown':
// middleware.ts
const forwarded = req.headers.get('x-forwarded-for');
const ip = forwarded ? forwarded.split(',')[0].trim() : 'unknown';
// ...
await apiRateLimiter.consume(ip);Per-user (AI chat)
The AI chat endpoint keys on the authenticated session email instead of the IP, so each signed-in user gets their own LLM budget regardless of where they connect from:
// app/api/ai/chat/route.ts
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
// Rate limit per user, LLM calls are expensive.
await aiRateLimiter.consume(session.user.email);Per-identifier (magic link)
The magic-link sign-in flow in libs/auth.ts consumes from magicLinkRateLimiter using the email identifier before sending the verification email, so a single address cannot trigger an unbounded number of sign-in emails.
Configuring magic-link limits
The magic-link limiter is the only one whose budget is configurable without code changes. Both values are validated in libs/config.ts with z.coerce.number(), so they are required and must be numeric. Set them in .env.local:
| Variable | Meaning | .env.example default |
|---|---|---|
RATE_LIMIT_MAGICLINK_MAX | points, max sign-in emails per window | 5 |
RATE_LIMIT_MAGICLINK_DURATION | duration, window length in seconds | 3600 (1 hour) |
# .env.local
# Magic Links rate limiting
RATE_LIMIT_MAGICLINK_MAX=5
RATE_LIMIT_MAGICLINK_DURATION=3600These two are part of the Zod schema in libs/config.ts, so the app will throw at startup if they are missing or non-numeric. See Environment variables for the full list.
What happens when a limit is exceeded
When a limiter has no points left, consume() rejects. Both the middleware and the route handlers catch that rejection, log a security event, and return HTTP 429.
In middleware
The middleware emits a RATE_LIMIT_EXCEEDED security event at WARNING severity (via securityLogger) including the path, method, IP, and user agent, then returns a JSON 429 with a Retry-After: 60 header:
// middleware.ts, on consume() rejection
securityLogger.log({
type: SecurityEventType.RATE_LIMIT_EXCEEDED,
severity: SecurityEventSeverity.WARNING,
message: `Rate limit exceeded for ${path}`,
ip, path, method: req.method,
userAgent: req.headers.get('user-agent') || 'unknown',
});
return NextResponse.json(
{ error: 'Too many requests, please try again later' },
{ status: 429, headers: { 'Retry-After': '60' } },
);In the AI chat route
The chat endpoint logs the same RATE_LIMIT_EXCEEDEDevent type (keyed on the user's email as userId) and returns 429 with a friendlier message:
// app/api/ai/chat/route.ts
try {
await aiRateLimiter.consume(session.user.email);
} catch {
securityLogger.log({
type: SecurityEventType.RATE_LIMIT_EXCEEDED,
severity: SecurityEventSeverity.WARNING,
message: 'AI chat rate limit exceeded',
userId: session.user.email,
});
return NextResponse.json(
{ error: 'Too many requests. Please slow down.' },
{ status: 429 },
);
}These events flow through the same pipeline as other security events, see Security monitoring.
Consuming a limiter in your own route
To rate-limit a custom API route, import the limiter you want and wrap consume() in a try/catch. Pick a key that represents the client, the session email for per-user limits, or the IP for anonymous endpoints. You can reuse an existing limiter or add a new RateLimiterMemory in libs/rateLimiter.ts.
// app/api/my-route/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/libs/auth';
import { sensitiveOpRateLimiter } from '@/libs/rateLimiter';
export async function POST() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
try {
await sensitiveOpRateLimiter.consume(session.user.email);
} catch {
return NextResponse.json(
{ error: 'Too many requests, please try again later' },
{ status: 429, headers: { 'Retry-After': '60' } },
);
}
// ...handle the request
return NextResponse.json({ ok: true });
}A standalone helper, applyRateLimit(req, path) in middleware/rateLimit.ts, also performs the same path-based selection over apiRateLimiter, authRateLimiter, and sensitiveOpRateLimiter if you prefer to apply limits inside a route by path.
In-memory store and Redis for multiple instances
Every limiter uses RateLimiterMemory, which keeps its counters in the Node process's memory. That is simple and dependency-free, but it has two consequences you should plan for:
- Counters reset on restart / redeploy. A new process starts with full budgets.
- Counters are not shared across instances. On a platform that runs several instances (or scales serverless functions), each instance counts independently, so the effective limit is roughly your per-instance budget multiplied by the number of instances.
For a single long-running instance this is fine. For multi-instance or serverless deployments where a global limit matters, swap RateLimiterMemory for the Redis-backed limiter that rate-limiter-flexible already provides. The construction options (points, duration) stay the same:
// libs/rateLimiter.ts (Redis variant, conceptual)
import Redis from 'ioredis';
import { RateLimiterRedis } from 'rate-limiter-flexible';
const redisClient = new Redis(process.env.REDIS_URL);
export const apiRateLimiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'rl_api',
points: 60,
duration: 60,
});Because routes only call consume(), swapping the store is local to libs/rateLimiter.ts, no call sites change. Note the boilerplate ships only the in-memory variant; adding Redis means installing a client and providing a connection URL yourself.
