ShipVeryFastShipVeryFast
Documentation

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,
});
LimiterBudgetKeyUsed by
magicLinkRateLimiterenv-driven (default 5 / 3600s)email identifierlibs/auth.ts (EmailProvider)
apiRateLimiter60 / 60sclient IPmiddleware.ts (general fallback)
authRateLimiter10 / 60sclient IPmiddleware.ts (/api/auth)
sensitiveOpRateLimiter5 / 60sclient IPmiddleware.ts (password / email / subscription)
aiRateLimiter20 / 60ssession emailapp/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:

VariableMeaning.env.example default
RATE_LIMIT_MAGICLINK_MAXpoints, max sign-in emails per window5
RATE_LIMIT_MAGICLINK_DURATIONduration, window length in seconds3600 (1 hour)
# .env.local
# Magic Links rate limiting
RATE_LIMIT_MAGICLINK_MAX=5
RATE_LIMIT_MAGICLINK_DURATION=3600

These 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.

Next steps