| name | secure-nextjs-api-routes |
| description | A comprehensive security middleware system for Next.js 13+ App Router API routes that provides authentication, rate limiting, CSRF protection, audit logging, and security headers in a composable, production-ready pattern. Use when building secure Next.js APIs that need protection against common web vulnerabilities. |
Secure Next.js API Routes
A comprehensive security middleware system for Next.js 13+ App Router API routes that provides authentication, rate limiting, CSRF protection, audit logging, and security headers in a composable, production-ready pattern.
When to use this skill
- Creating new Next.js API routes that need security
- Adding authentication requirements to endpoints
- Implementing rate limiting for API endpoints
- Protecting against CSRF attacks on state-changing operations
- Adding audit logging for security events
- Enforcing request size limits and method restrictions
- Setting security headers automatically
Core Components
This skill consists of 4 integrated modules:
- Security Middleware (
lib/security-middleware.ts) - Main composable wrapper
- CSRF Protection (
lib/csrf-protection.ts) - Double-submit cookie pattern
- Rate Limiter (
lib/rate-limiter.ts) - Supabase-backed rate limiting
- Audit Logger (
lib/audit-logger.ts) - Security event tracking
Implementation Steps
Step 1: Create the Security Middleware
Create lib/security-middleware.ts:
import { NextRequest, NextResponse } from 'next/server';
import { RateLimiter, RATE_LIMITS, rateLimitResponse } from '@/lib/rate-limiter';
import { AuditLogger, AuditAction } from '@/lib/audit-logger';
import { createClient } from '@/lib/supabase/server';
import { validateCSRF, injectCSRFToken } from '@/lib/csrf-protection';
export interface SecurityMiddlewareConfig {
rateLimit?: {
windowMs: number;
maxRequests: number;
};
requireAuth?: boolean;
maxBodySize?: number;
allowedMethods?: string[];
csrfProtection?: boolean;
}
export function withSecurity(
handler: (req: NextRequest) => Promise<NextResponse>,
config: SecurityMiddlewareConfig = {}
) {
return async function securedHandler(req: NextRequest): Promise<NextResponse> {
try {
if (config.allowedMethods && !config.allowedMethods.includes(req.method)) {
return NextResponse.json(
{ error: 'Method not allowed' },
{ status: 405 }
);
}
if (config.requireAuth) {
const supabase = await createClient();
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) {
await AuditLogger.logSecurityEvent(
AuditAction.UNAUTHORIZED_ACCESS,
{ endpoint: req.url }
);
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
}
if (config.rateLimit) {
const rateLimitResult = await RateLimiter.check(
req.url,
config.rateLimit
);
if (!rateLimitResult.allowed) {
await AuditLogger.logRateLimitExceeded(
req.url,
'api-endpoint'
);
return rateLimitResponse(rateLimitResult) || NextResponse.json(
{ error: 'Rate limit exceeded' },
{ status: 429 }
);
}
}
if (['POST', 'PUT', 'PATCH'].includes(req.method) && config.maxBodySize) {
const contentLength = req.headers.get('content-length');
if (contentLength && parseInt(contentLength) > config.maxBodySize) {
return NextResponse.json(
{ error: 'Request body too large' },
{ status: 413 }
);
}
}
if (config.csrfProtection && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
const csrfValidation = await validateCSRF(req);
if (!csrfValidation.valid) {
await AuditLogger.logSecurityEvent(
AuditAction.UNAUTHORIZED_ACCESS,
{
endpoint: req.url,
reason: 'CSRF validation failed',
error: csrfValidation.error
}
);
return NextResponse.json(
{ error: csrfValidation.error || 'CSRF validation failed' },
{ status: 403 }
);
}
}
const response = await handler(req);
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-XSS-Protection', '1; mode=block');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
const origin = req.headers.get('origin');
if (origin && isAllowedOrigin(origin)) {
response.headers.set('Access-Control-Allow-Origin', origin);
response.headers.set('Access-Control-Allow-Credentials', 'true');
}
if (config.csrfProtection) {
const { response: csrfResponse } = injectCSRFToken(response);
return csrfResponse;
}
return response;
} catch (error) {
console.error('Security middleware error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
};
}
function isAllowedOrigin(origin: string): boolean {
const allowedOrigins = [
process.env.NEXT_PUBLIC_BASE_URL,
'http://localhost:3000',
'http://localhost:3001',
].filter(Boolean);
return allowedOrigins.includes(origin);
}
export const SECURITY_PRESETS = {
PUBLIC: {
rateLimit: RATE_LIMITS.API_GENERAL,
maxBodySize: 1024 * 1024,
allowedMethods: ['GET', 'POST']
},
AUTHENTICATED: {
requireAuth: true,
rateLimit: RATE_LIMITS.AUTH_GENERATION,
maxBodySize: 5 * 1024 * 1024,
allowedMethods: ['GET', 'POST', 'PUT', 'DELETE'],
csrfProtection: true
},
STRICT: {
requireAuth: true,
rateLimit: {
windowMs: 60 * 1000,
maxRequests: 10
},
maxBodySize: 512 * 1024,
allowedMethods: ['POST'],
csrfProtection: true
}
};
Step 2: Create CSRF Protection
Create lib/csrf-protection.ts:
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
const CSRF_TOKEN_HEADER = 'X-CSRF-Token';
const CSRF_TOKEN_COOKIE = 'csrf-token';
const TOKEN_LENGTH = 32;
export function generateCSRFToken(): string {
return crypto.randomBytes(TOKEN_LENGTH).toString('hex');
}
export function setCSRFTokenCookie(response: NextResponse, token: string): void {
response.cookies.set(CSRF_TOKEN_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
maxAge: 60 * 60 * 24
});
}
(): | {
request..()?. || ;
}
(): | {
request..();
}
(): {
(!cookieToken || !headerToken) ;
(cookieToken !== headerToken) ;
(cookieToken. !== * ) ;
;
}
(): <{ : ; ?: }> {
([, , ].(request.)) {
{ : };
}
cookieToken = (request);
headerToken = (request);
(!(cookieToken, headerToken)) {
{
: ,
:
};
}
{ : };
}
(): { : ; : } {
token = ();
(response, token);
response..(, token);
{ token, response };
}
Step 3: Create Rate Limiter
Create lib/rate-limiter.ts:
import { createClient } from '@/lib/supabase/server';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import crypto from 'crypto';
interface RateLimitConfig {
windowMs: number;
maxRequests: number;
identifier?: string;
}
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: Date;
retryAfter?: number;
}
export class RateLimiter {
private static async getIdentifier(customId?: string): Promise<string> {
if (customId) return customId;
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
(user) ;
headersList = ();
forwardedFor = headersList.();
realIp = headersList.();
ip = forwardedFor?.()[] || realIp || ;
hash = crypto.().(ip).();
;
}
(
: ,
:
): <> {
identifier = .(config.);
rateLimitKey = ;
supabase = ();
now = .();
windowStart = now - config.;
{
supabase
.()
.()
.(, (windowStart).());
{ : recentRequests, error } = supabase
.()
.()
.(, rateLimitKey)
.(, (windowStart).());
(error) error;
requestCount = recentRequests?. || ;
remaining = .(, config. - requestCount);
resetAt = (now + config.);
(requestCount >= config.) {
{ : oldestRequest } = supabase
.()
.()
.(, rateLimitKey)
.(, { : })
.()
.();
retryAfter = .(config. / );
(oldestRequest) {
oldestTime = (oldestRequest.).();
retryAfter = .((oldestTime + config. - now) / );
}
{ : , : , resetAt, retryAfter };
}
supabase.().({
: rateLimitKey,
: (now).(),
identifier
});
{ : , : remaining - , resetAt };
} (error) {
.(, error);
{
: ,
: config.,
: (now + config.)
};
}
}
}
= {
: {
: * ,
:
},
: {
: * * ,
:
},
: {
: * * * ,
:
}
};
(): | {
: = {
: result..(),
: result..()
};
(!result. && result.) {
headers[] = result..();
.(
{
: ,
: ,
: result.,
: result.
},
{ : , headers }
);
}
;
}
Step 4: Create Audit Logger
Create lib/audit-logger.ts:
import { createClient } from '@/lib/supabase/server';
export enum AuditAction {
UNAUTHORIZED_ACCESS = 'unauthorized_access',
RATE_LIMIT_EXCEEDED = 'rate_limit_exceeded',
CSRF_VALIDATION_FAILED = 'csrf_validation_failed',
INVALID_INPUT = 'invalid_input',
SECURITY_EVENT = 'security_event'
}
export class AuditLogger {
static async logSecurityEvent(
action: AuditAction,
metadata?: Record<string, unknown>
): Promise<void> {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
await supabase.from('audit_logs').insert({
action,
user_id: user?.id || null,
metadata,
timestamp: new Date().()
});
} (error) {
.(, error);
}
}
(
: ,
:
): <> {
.(., {
endpoint,
resourceType
});
}
}
Step 5: Create Database Tables
Run this SQL in your Supabase SQL editor:
CREATE TABLE IF NOT EXISTS rate_limits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL,
identifier TEXT NOT NULL,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_rate_limits_key_timestamp ON rate_limits(key, timestamp);
CREATE INDEX idx_rate_limits_timestamp ON rate_limits(timestamp);
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
action TEXT NOT NULL,
user_id UUID REFERENCES auth.users(id),
metadata JSONB,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_timestamp ON audit_logs(timestamp);
Step 6: Create Client-Side CSRF Fetch Helper
Create lib/csrf-client.ts:
export async function csrfFetch(
url: string,
options: RequestInit = {}
): Promise<Response> {
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrf-token='))
?.split('=')[1];
const method = options.method?.toUpperCase() || 'GET';
const needsCSRF = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
const headers = new Headers(options.headers);
if (needsCSRF && csrfToken) {
headers.set('X-CSRF-Token', csrfToken);
}
return fetch(url, {
...options,
headers
});
}
Usage Examples
Example 1: Public API Route with Rate Limiting
import { NextRequest, NextResponse } from 'next/server';
import { withSecurity, SECURITY_PRESETS } from '@/lib/security-middleware';
async function handler(req: NextRequest) {
const data = await fetchPublicData();
return NextResponse.json({ data });
}
export const GET = withSecurity(handler, SECURITY_PRESETS.PUBLIC);
Example 2: Authenticated Route with CSRF Protection
import { NextRequest, NextResponse } from 'next/server';
import { withSecurity, SECURITY_PRESETS } from '@/lib/security-middleware';
async function handler(req: NextRequest) {
const body = await req.json();
return NextResponse.json({ success: true });
}
export const POST = withSecurity(handler, SECURITY_PRESETS.AUTHENTICATED);
Example 3: Custom Security Configuration
import { NextRequest, NextResponse } from 'next/server';
import { withSecurity } from '@/lib/security-middleware';
async function handler(req: NextRequest) {
return NextResponse.json({ success: true });
}
export const POST = withSecurity(handler, {
requireAuth: true,
csrfProtection: true,
rateLimit: {
windowMs: 60 * 60 * 1000,
maxRequests: 5
},
maxBodySize: 100 * 1024,
allowedMethods: ['POST']
});
Example 4: Client-Side Usage with CSRF
import { csrfFetch } from '@/lib/csrf-client';
async function saveNote(noteData) {
const response = await csrfFetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(noteData)
});
return response.json();
}
Security Best Practices
- Always use SECURITY_PRESETS for consistency unless you need custom config
- Enable CSRF protection for all state-changing operations (POST, PUT, PATCH, DELETE)
- Use strict rate limits for expensive operations (AI generation, file uploads)
- Log security events for monitoring and incident response
- Keep audit logs for compliance and debugging
- Use csrfFetch on client-side for all authenticated mutations
- Set appropriate maxBodySize to prevent DoS attacks
- Review audit logs regularly for suspicious activity
Common Pitfalls
- Forgetting CSRF tokens on client: Always use
csrfFetch for mutations
- Too lenient rate limits: Start strict, loosen based on usage patterns
- Not handling 429 responses: Show user-friendly retry messages
- Logging sensitive data: Never log passwords, tokens, or PII in audit logs
- Missing database indices: Rate limiting table needs indices for performance
- Not cleaning up old records: Set up a cron job to delete old rate_limits rows
Environment Variables Required
NEXT_PUBLIC_SUPABASE_URL=your-supabase-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
CSRF_SALT=your-random-secret-salt
Testing Your Implementation
for (let i = 0; i < 100; i++) {
const response = await fetch('/api/protected');
console.log(response.status);
}
const response = await fetch('/api/protected', {
method: 'POST',
});
const response = await fetch('/api/authenticated');
Next Steps
After implementing this skill:
- Add monitoring for rate limit events
- Set up alerts for repeated unauthorized access attempts
- Create a dashboard to view audit logs
- Implement IP-based blocking for repeated violations
- Add request fingerprinting for additional security