Rate Limiting & Validation

Category-based API rate limiting with Upstash Redis, Zod input validation, and XSS sanitization utilities

Kit protects API endpoints with three inner defense layers: rate limiting (Upstash Redis with category-based limits), input validation (centralized Zod schemas), and sanitization (XSS, SQL, path traversal, URL, and email protection). These layers work together to ensure that even if a request passes authentication, it cannot abuse your API or inject malicious data.
For the overall security architecture, see Security Overview. For Redis setup instructions, see Caching & Redis.

Rate Limit Categories

Every API endpoint belongs to a category with independent user-based and IP-based limits. This lets you set tight limits on sensitive operations (like email sending) while keeping general API endpoints more permissive:
src/lib/security/api-rate-limiter.ts — API_LIMITS Configuration
const API_LIMITS: Record<
  APICategory,
  { user?: RateLimitConfig; ip?: RateLimitConfig }
> = {
  upload: {
    user: { requests: 10, window: '1 h', identifier: 'user' },
    ip: { requests: 20, window: '1 h', identifier: 'ip' },
  },
  email: {
    user: { requests: 5, window: '1 h', identifier: 'user' },
    ip: { requests: 10, window: '1 h', identifier: 'ip' },
  },
  contact: {
    ip: { requests: 3, window: '1 h', identifier: 'ip' },
  },
  payments: {
    user: { requests: 20, window: '1 h', identifier: 'user' },
  },
  webhooks: {
    ip: { requests: 100, window: '1 h', identifier: 'ip' },
  },
  api: {
    user: { requests: 100, window: '1 h', identifier: 'user' },
    ip: { requests: 200, window: '1 h', identifier: 'ip' },
  },
}

Category Reference

CategoryUser LimitIP LimitTypical Endpoints
upload10 req/hour20 req/hourFile upload (/api/upload)
email5 req/hour10 req/hourEmail sending (/api/email/send)
contact3 req/hourContact form (/api/contact) — no auth required
payments20 req/hourCheckout, subscription management
webhooks100 req/hourExternal webhook processing
api100 req/hour200 req/hourGeneral API catch-all
When both user and IP limits are configured for a category, the request must pass both checks. The most restrictive result is returned to the client.

Using withRateLimit

The withRateLimit middleware factory wraps any API route handler with automatic rate limiting. It handles identifier extraction, limit checking, and response headers:
src/lib/security/rate-limit-middleware.ts — withRateLimit Signature
export function withRateLimit(
  category: APICategory,
  handler: (request: NextRequest) => Promise<NextResponse>
): (request: NextRequest) => Promise<NextResponse> {

Basic Usage

typescript
import { NextResponse } from 'next/server'
import { withRateLimit } from '@/lib/security/rate-limit-middleware'

export const POST = withRateLimit('upload', async (request) => {
  // This code only runs if rate limit check passes
  const formData = await request.formData()
  // ... handle upload
  return NextResponse.json({ success: true })
})
The middleware automatically:
  1. Extracts the user ID from Clerk authentication (or falls back to test user)
  2. Extracts the client IP from x-forwarded-for or x-real-ip headers
  3. Checks both user and IP rate limits for the given category
  4. Returns 429 Too Many Requests with retry information if exceeded
  5. Adds X-RateLimit-* headers to every response (success and failure)
  6. On middleware error, fails open and calls the handler directly

Response Headers

Every response from a rate-limited endpoint includes standard headers so clients can track their usage programmatically:
HeaderExample ValueDescription
X-RateLimit-Limit10Maximum requests allowed in the current window
X-RateLimit-Remaining7Requests remaining before hitting the limit
X-RateLimit-Reset1708012800000Unix timestamp (ms) when the window resets

429 Response Format

When the rate limit is exceeded, the client receives a structured JSON response with all the information needed to implement retry logic:
json
{
  "error": "Rate limit exceeded",
  "message": "Too many requests. Please try again in 45 minutes.",
  "retryAfter": "45 minutes",
  "limit": 10,
  "remaining": 0,
  "reset": 1708012800000
}
The retryAfter field provides a human-readable duration. The reset field provides the exact Unix timestamp for programmatic retry scheduling.

Input Validation with Zod

Every API endpoint validates its input using centralized Zod schemas defined in apps/boilerplate/src/lib/validations/api-schemas.ts. This prevents malformed or malicious data from reaching your business logic.

Schema Organization

Schemas are grouped by feature area:
SchemaFieldsUsed By
contactSchemaname (2-100 chars), email, subject (3-200), message (10-5000)/api/contact
emailSchemato (email), subject (1-200), html, text, type/api/email/send
createCheckoutSchemavariantId, productId, redirectUrl, cancelUrl, discountCode/api/payments/checkout
uploadFileSchemafilename (1-255), filesize (max 4.5MB), filetype (MIME)/api/upload
newsletterSubscribeSchemaemail, name (optional, 2-100)/api/newsletter
updateUserSchemaname, email, bio (max 500), avatar (URL)/api/user/update
bonusCreditCheckoutSchemavariantId (string, non-empty)/api/credits/checkout
createCheckoutUrlSchemavariantId (string, non-empty), embed (optional boolean)/api/checkout/create-url
bonusPreferencesSchemabonusCreditsAutoUse (boolean)/api/credits/preferences
deleteUserSchemaconfirmEmail (email)/api/user/delete

Representative Schema Example

src/lib/validations/api-schemas.ts — Contact Schema
export const contactSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters').max(100),
  email: z.string().email('Invalid email address'),
  subject: z.string().min(3, 'Subject must be at least 3 characters').max(200),
  message: z
    .string()
    .min(10, 'Message must be at least 10 characters')
    .max(5000),
})

Usage Pattern

typescript
import { contactSchema } from '@/lib/validations/api-schemas'

export const POST = withRateLimit('contact', async (request) => {
  const body = await request.json()

  // Validate input against schema
  const result = contactSchema.safeParse(body)
  if (!result.success) {
    return NextResponse.json(
      { error: 'Validation failed', details: result.error.flatten() },
      { status: 400 }
    )
  }

  // result.data is now fully typed and validated
  const { name, email, subject, message } = result.data
  // ... send contact email
})
Zod schemas serve as both runtime validation and TypeScript type inference. Every schema exports a corresponding type (e.g., ContactSchemaType) that you can use in your application code.

Sanitization Utilities

After validation, data passes through sanitization functions that remove potentially dangerous content. Kit includes a comprehensive sanitization library:

Function Reference

FunctionPurposeExample InputExample Output
sanitizeHtml()Remove XSS vectors (scripts, iframes, event handlers)<script>alert('xss')</script><p>Hi</p><p>Hi</p>
sanitizeFilename()Prevent path traversal and command injection../../../etc/passwdetc_passwd
sanitizeUrl()Block dangerous protocols (javascript:, data:)javascript:alert('xss')"" (empty)
sanitizeEmail()Prevent email header injectionuser@example.com\r\nBCC:hacker@evil.comuser@example.com
escapeSqlLike()Escape LIKE wildcards for safe SQL queries100%_done100\%\_done
sanitizeJson()Remove prototype pollution keys{"__proto__": {"admin": true}}{}
sanitizeText()Remove control characters, normalize whitespaceHello\x00WorldHello World
sanitizeInteger()Clamp numeric input to safe range"999999" (with max=100)100
sanitizeBoolean()Parse various boolean representations"yes"true
sanitizeErrorMessage()Strip sensitive info from error responsesError: DB connection string: postgres://..."An unexpected error occurred"

Sanitization Example

typescript
import {
  sanitizeFilename,
  sanitizeUrl,
  sanitizeHtml,
  sanitizeText,
} from '@/lib/security/sanitization'

// Sanitize file upload metadata
const safeName = sanitizeFilename(userProvidedFilename) // Removes path traversal
const safeUrl = sanitizeUrl(userProvidedUrl)             // Blocks javascript: URLs
const safeHtml = sanitizeHtml(userProvidedHtml)          // Strips <script> tags
const safeText = sanitizeText(userProvidedBio, 500)      // Removes control chars, limits length

Complete API Route Example

Here is a complete API route that demonstrates all three inner defense layers working together — rate limiting, validation, and sanitization:
typescript
import { NextRequest, NextResponse } from 'next/server'
import { withRateLimit } from '@/lib/security/rate-limit-middleware'
import { contactSchema } from '@/lib/validations/api-schemas'
import { sanitizeText, sanitizeEmail } from '@/lib/security/sanitization'

export const POST = withRateLimit('contact', async (request: NextRequest) => {
  // Layer 2: Rate limiting (handled by withRateLimit wrapper)

  // Parse request body
  const body = await request.json()

  // Layer 3: Input validation with Zod
  const result = contactSchema.safeParse(body)
  if (!result.success) {
    return NextResponse.json(
      { error: 'Validation failed', details: result.error.flatten() },
      { status: 400 }
    )
  }

  // Layer 4: Sanitization
  const name = sanitizeText(result.data.name, 100)
  const email = sanitizeEmail(result.data.email)
  const subject = sanitizeText(result.data.subject, 200)
  const message = sanitizeText(result.data.message, 5000)

  if (!email) {
    return NextResponse.json(
      { error: 'Invalid email address' },
      { status: 400 }
    )
  }

  // Business logic — all input is now validated and sanitized
  await sendContactEmail({ name, email, subject, message })

  return NextResponse.json({ success: true })
})
This pattern ensures:
  1. Rate limiting stops abuse before any processing occurs
  2. Zod validation rejects structurally invalid requests with clear error messages
  3. Sanitization cleans the data to prevent XSS, injection, and other attacks
  4. Business logic only receives safe, validated, and typed data

Customizing Rate Limits

Adding a New Category

To add a custom rate limit category, extend the API_LIMITS configuration and the APICategory type:
typescript
// In apps/boilerplate/src/lib/security/api-rate-limiter.ts

// 1. Add to the union type
export type APICategory =
  | 'upload'
  | 'email'
  | 'contact'
  | 'payments'
  | 'webhooks'
  | 'api'
  | 'search'  // New category

// 2. Add to API_LIMITS
const API_LIMITS = {
  // ... existing categories
  search: {
    user: { requests: 50, window: '1 h', identifier: 'user' },
    ip: { requests: 100, window: '1 h', identifier: 'ip' },
  },
}
Then use it in your API route:
typescript
export const GET = withRateLimit('search', async (request) => {
  // Search logic
})

Adjusting Existing Limits

Modify the requests and window values in the API_LIMITS object. The sliding window algorithm automatically handles the new values — no Redis reconfiguration needed.

Key Files

FilePurpose
apps/boilerplate/src/lib/security/api-rate-limiter.tsCategory-based rate limiting with Upstash Redis
apps/boilerplate/src/lib/security/rate-limit-middleware.tswithRateLimit() factory and checkRateLimitOnly() helper
apps/boilerplate/src/lib/validations/api-schemas.tsCentralized Zod schemas for all API endpoints
apps/boilerplate/src/lib/security/sanitization.tsSanitization functions (HTML, filename, URL, email, SQL, JSON)