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
| Category | User Limit | IP Limit | Typical Endpoints |
|---|---|---|---|
upload | 10 req/hour | 20 req/hour | File upload (/api/upload) |
email | 5 req/hour | 10 req/hour | Email sending (/api/email/send) |
contact | — | 3 req/hour | Contact form (/api/contact) — no auth required |
payments | 20 req/hour | — | Checkout, subscription management |
webhooks | — | 100 req/hour | External webhook processing |
api | 100 req/hour | 200 req/hour | General 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:
- Extracts the user ID from Clerk authentication (or falls back to test user)
- Extracts the client IP from
x-forwarded-fororx-real-ipheaders - Checks both user and IP rate limits for the given category
- Returns 429 Too Many Requests with retry information if exceeded
- Adds
X-RateLimit-*headers to every response (success and failure) - 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:
| Header | Example Value | Description |
|---|---|---|
X-RateLimit-Limit | 10 | Maximum requests allowed in the current window |
X-RateLimit-Remaining | 7 | Requests remaining before hitting the limit |
X-RateLimit-Reset | 1708012800000 | Unix 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:
| Schema | Fields | Used By |
|---|---|---|
contactSchema | name (2-100 chars), email, subject (3-200), message (10-5000) | /api/contact |
emailSchema | to (email), subject (1-200), html, text, type | /api/email/send |
createCheckoutSchema | variantId, productId, redirectUrl, cancelUrl, discountCode | /api/payments/checkout |
uploadFileSchema | filename (1-255), filesize (max 4.5MB), filetype (MIME) | /api/upload |
newsletterSubscribeSchema | email, name (optional, 2-100) | /api/newsletter |
updateUserSchema | name, email, bio (max 500), avatar (URL) | /api/user/update |
bonusCreditCheckoutSchema | variantId (string, non-empty) | /api/credits/checkout |
createCheckoutUrlSchema | variantId (string, non-empty), embed (optional boolean) | /api/checkout/create-url |
bonusPreferencesSchema | bonusCreditsAutoUse (boolean) | /api/credits/preferences |
deleteUserSchema | confirmEmail (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
| Function | Purpose | Example Input | Example 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/passwd | etc_passwd |
sanitizeUrl() | Block dangerous protocols (javascript:, data:) | javascript:alert('xss') | "" (empty) |
sanitizeEmail() | Prevent email header injection | user@example.com\r\nBCC:hacker@evil.com | user@example.com |
escapeSqlLike() | Escape LIKE wildcards for safe SQL queries | 100%_done | 100\%\_done |
sanitizeJson() | Remove prototype pollution keys | {"__proto__": {"admin": true}} | {} |
sanitizeText() | Remove control characters, normalize whitespace | Hello\x00World | Hello 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 responses | Error: 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
Validation (Zod) rejects bad input entirely — the request fails with 400. Sanitization transforms input to make it safe — the request continues with cleaned data. Use validation for structural requirements (field types, lengths) and sanitization for content cleaning (removing scripts from HTML, normalizing filenames).
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:
- Rate limiting stops abuse before any processing occurs
- Zod validation rejects structurally invalid requests with clear error messages
- Sanitization cleans the data to prevent XSS, injection, and other attacks
- 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.While the system works without Redis (fail-open), you should always have Redis configured in production. Without it, there is no protection against API abuse. Monitor the
"Rate limiting disabled" warning log to detect misconfigurations early.Key Files
| File | Purpose |
|---|---|
apps/boilerplate/src/lib/security/api-rate-limiter.ts | Category-based rate limiting with Upstash Redis |
apps/boilerplate/src/lib/security/rate-limit-middleware.ts | withRateLimit() factory and checkRateLimitOnly() helper |
apps/boilerplate/src/lib/validations/api-schemas.ts | Centralized Zod schemas for all API endpoints |
apps/boilerplate/src/lib/security/sanitization.ts | Sanitization functions (HTML, filename, URL, email, SQL, JSON) |