nextjs-security
Next.js 15 security patterns for veterinary platforms including Server Action hardening, CSRF protection, rate limiting, RLS policy generation, and auth middleware. Use when building or auditing security features.
When & Why to Use This Skill
This Claude skill provides a comprehensive suite of Next.js 15 security patterns specifically designed for multi-tenant platforms. It offers production-ready implementations for Server Action hardening, CSRF protection, Upstash-based rate limiting, and automated Supabase Row Level Security (RLS) policy generation, ensuring robust data isolation and application integrity for sensitive environments like veterinary clinics.
Use Cases
- Case 1: Building a secure multi-tenant SaaS application with Next.js 15 and Supabase that requires strict data isolation between different organizations.
- Case 2: Hardening Next.js Server Actions and API routes by implementing centralized authentication, role-based access control (RBAC), and input validation using Zod.
- Case 3: Implementing distributed rate limiting and CSRF protection to defend against brute-force attacks and cross-site request forgery in high-traffic web applications.
- Case 4: Automating the generation of complex PostgreSQL RLS policies to ensure users can only access records associated with their specific tenant or profile.
- Case 5: Auditing and upgrading web application security headers and Content Security Policy (CSP) settings via Next.js middleware to meet modern security standards.
| name | nextjs-security |
|---|---|
| description | Next.js 15 security patterns for veterinary platforms including Server Action hardening, CSRF protection, rate limiting, RLS policy generation, and auth middleware. Use when building or auditing security features. |
Next.js 15 Security Patterns
Overview
This skill covers security best practices specific to Next.js 15 App Router applications with Supabase, focusing on multi-tenant veterinary platform requirements.
1. Server Action Security
Secure Server Action Pattern
// lib/actions/secure-action.ts
import { createClient } from '@/lib/supabase/server';
import { z } from 'zod';
import { headers } from 'next/headers';
import { rateLimit } from '@/lib/rate-limit';
interface ActionContext {
user: User;
profile: Profile;
supabase: SupabaseClient;
}
type SecureActionOptions = {
schema?: z.ZodSchema;
roles?: ('owner' | 'vet' | 'admin')[];
rateLimit?: { requests: number; window: string };
};
export function createSecureAction<TInput, TOutput>(
options: SecureActionOptions,
handler: (input: TInput, context: ActionContext) => Promise<TOutput>
) {
return async (input: TInput): Promise<{ success: true; data: TOutput } | { success: false; error: string }> => {
try {
// 1. Rate limiting
if (options.rateLimit) {
const headersList = await headers();
const ip = headersList.get('x-forwarded-for') || 'unknown';
const limited = await rateLimit(ip, options.rateLimit);
if (limited) {
return { success: false, error: 'Demasiadas solicitudes. Intenta más tarde.' };
}
}
// 2. Authentication
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return { success: false, error: 'No autorizado' };
}
// 3. Get profile and tenant context
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('id, tenant_id, role, full_name')
.eq('id', user.id)
.single();
if (profileError || !profile) {
return { success: false, error: 'Perfil no encontrado' };
}
// 4. Role authorization
if (options.roles && !options.roles.includes(profile.role)) {
return { success: false, error: 'No tienes permiso para esta acción' };
}
// 5. Input validation
if (options.schema) {
const validation = options.schema.safeParse(input);
if (!validation.success) {
return {
success: false,
error: validation.error.issues.map(i => i.message).join(', '),
};
}
input = validation.data;
}
// 6. Execute handler
const result = await handler(input, { user, profile, supabase });
return { success: true, data: result };
} catch (error) {
console.error('Action error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Error interno del servidor',
};
}
};
}
// Usage example
const updatePetSchema = z.object({
petId: z.string().uuid(),
name: z.string().min(1).max(50),
weight: z.number().positive().optional(),
});
export const updatePet = createSecureAction(
{
schema: updatePetSchema,
roles: ['owner', 'vet', 'admin'],
rateLimit: { requests: 10, window: '1m' },
},
async (input, { profile, supabase }) => {
// Verify ownership or staff access
const { data: pet } = await supabase
.from('pets')
.select('owner_id, tenant_id')
.eq('id', input.petId)
.single();
if (!pet) {
throw new Error('Mascota no encontrada');
}
// Owner can only edit their own pets
if (profile.role === 'owner' && pet.owner_id !== profile.id) {
throw new Error('No tienes permiso para editar esta mascota');
}
// Staff can only edit pets in their tenant
if (profile.role !== 'owner' && pet.tenant_id !== profile.tenant_id) {
throw new Error('Esta mascota no pertenece a tu clínica');
}
const { data, error } = await supabase
.from('pets')
.update({ name: input.name, weight: input.weight })
.eq('id', input.petId)
.select()
.single();
if (error) throw error;
return data;
}
);
CSRF Protection
// lib/csrf.ts
import { cookies } from 'next/headers';
import { randomBytes } from 'crypto';
const CSRF_COOKIE_NAME = '__Host-csrf';
const CSRF_HEADER_NAME = 'x-csrf-token';
export async function generateCSRFToken(): Promise<string> {
const token = randomBytes(32).toString('hex');
const cookieStore = await cookies();
cookieStore.set(CSRF_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
maxAge: 60 * 60 * 24, // 24 hours
});
return token;
}
export async function validateCSRFToken(headerToken: string): Promise<boolean> {
const cookieStore = await cookies();
const cookieToken = cookieStore.get(CSRF_COOKIE_NAME)?.value;
if (!cookieToken || !headerToken) {
return false;
}
// Timing-safe comparison
return timingSafeEqual(cookieToken, headerToken);
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
// Middleware for CSRF validation on state-changing requests
export async function csrfMiddleware(request: Request): Promise<Response | null> {
const method = request.method.toUpperCase();
// Only validate state-changing methods
if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
return null;
}
const headerToken = request.headers.get(CSRF_HEADER_NAME);
if (!headerToken || !(await validateCSRFToken(headerToken))) {
return new Response(JSON.stringify({ error: 'Invalid CSRF token' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
return null;
}
2. API Route Security
Secure API Route Pattern
// lib/api/secure-route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { z } from 'zod';
import { rateLimit } from '@/lib/rate-limit';
import { apiError, HTTP_STATUS } from '@/lib/api/errors';
interface RouteContext {
user: User;
profile: Profile;
supabase: SupabaseClient;
params: Record<string, string>;
}
type RouteOptions = {
roles?: ('owner' | 'vet' | 'admin')[];
rateLimit?: { requests: number; window: string };
validateBody?: z.ZodSchema;
validateParams?: z.ZodSchema;
validateQuery?: z.ZodSchema;
};
export function createSecureRoute<T = unknown>(
options: RouteOptions,
handler: (request: NextRequest, context: RouteContext) => Promise<T>
) {
return async (
request: NextRequest,
{ params }: { params: Promise<Record<string, string>> }
): Promise<NextResponse> => {
try {
// 1. Rate limiting
if (options.rateLimit) {
const ip = request.headers.get('x-forwarded-for') || request.ip || 'unknown';
const limited = await rateLimit(ip, options.rateLimit);
if (limited) {
return apiError('RATE_LIMITED', HTTP_STATUS.TOO_MANY_REQUESTS);
}
}
// 2. Authentication
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return apiError('UNAUTHORIZED', HTTP_STATUS.UNAUTHORIZED);
}
// 3. Get profile
const { data: profile } = await supabase
.from('profiles')
.select('id, tenant_id, role')
.eq('id', user.id)
.single();
if (!profile) {
return apiError('PROFILE_NOT_FOUND', HTTP_STATUS.FORBIDDEN);
}
// 4. Role check
if (options.roles && !options.roles.includes(profile.role)) {
return apiError('FORBIDDEN', HTTP_STATUS.FORBIDDEN);
}
// 5. Validate params
const resolvedParams = await params;
if (options.validateParams) {
const result = options.validateParams.safeParse(resolvedParams);
if (!result.success) {
return apiError('VALIDATION_ERROR', HTTP_STATUS.BAD_REQUEST, {
details: { errors: result.error.issues },
});
}
}
// 6. Validate query params
if (options.validateQuery) {
const url = new URL(request.url);
const query = Object.fromEntries(url.searchParams);
const result = options.validateQuery.safeParse(query);
if (!result.success) {
return apiError('VALIDATION_ERROR', HTTP_STATUS.BAD_REQUEST, {
details: { errors: result.error.issues },
});
}
}
// 7. Validate body (for POST/PUT/PATCH)
if (options.validateBody && ['POST', 'PUT', 'PATCH'].includes(request.method)) {
let body;
try {
body = await request.json();
} catch {
return apiError('INVALID_FORMAT', HTTP_STATUS.BAD_REQUEST);
}
const result = options.validateBody.safeParse(body);
if (!result.success) {
return apiError('VALIDATION_ERROR', HTTP_STATUS.BAD_REQUEST, {
details: { errors: result.error.issues },
});
}
}
// 8. Execute handler
const result = await handler(request, {
user,
profile,
supabase,
params: resolvedParams,
});
return NextResponse.json(result);
} catch (error) {
console.error('API error:', error);
return apiError('SERVER_ERROR', HTTP_STATUS.INTERNAL_SERVER_ERROR);
}
};
}
API Error Handling
// lib/api/errors.ts
import { NextResponse } from 'next/server';
export const HTTP_STATUS = {
OK: 200,
CREATED: 201,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
UNPROCESSABLE_ENTITY: 422,
TOO_MANY_REQUESTS: 429,
INTERNAL_SERVER_ERROR: 500,
} as const;
type ErrorCode =
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'VALIDATION_ERROR'
| 'INVALID_FORMAT'
| 'RATE_LIMITED'
| 'SERVER_ERROR'
| 'DATABASE_ERROR'
| 'PROFILE_NOT_FOUND'
| 'TENANT_MISMATCH';
const ERROR_MESSAGES: Record<ErrorCode, string> = {
UNAUTHORIZED: 'No autorizado',
FORBIDDEN: 'No tienes permiso para esta acción',
NOT_FOUND: 'Recurso no encontrado',
VALIDATION_ERROR: 'Datos inválidos',
INVALID_FORMAT: 'Formato de solicitud inválido',
RATE_LIMITED: 'Demasiadas solicitudes. Intenta más tarde.',
SERVER_ERROR: 'Error interno del servidor',
DATABASE_ERROR: 'Error de base de datos',
PROFILE_NOT_FOUND: 'Perfil no encontrado',
TENANT_MISMATCH: 'No tienes acceso a este recurso',
};
export function apiError(
code: ErrorCode,
status: number,
extra?: { details?: unknown; message?: string }
): NextResponse {
return NextResponse.json(
{
error: {
code,
message: extra?.message || ERROR_MESSAGES[code],
...(extra?.details && { details: extra.details }),
},
},
{ status }
);
}
3. Rate Limiting
Upstash Rate Limiter
// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
// Different limiters for different endpoints
export const rateLimiters = {
// General API: 100 requests per minute
api: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, '1m'),
analytics: true,
prefix: 'ratelimit:api',
}),
// Auth endpoints: 10 requests per minute
auth: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '1m'),
analytics: true,
prefix: 'ratelimit:auth',
}),
// Sensitive actions: 5 requests per minute
sensitive: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, '1m'),
analytics: true,
prefix: 'ratelimit:sensitive',
}),
// File uploads: 20 requests per hour
upload: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, '1h'),
analytics: true,
prefix: 'ratelimit:upload',
}),
};
export async function rateLimit(
identifier: string,
config: { requests: number; window: string }
): Promise<boolean> {
const limiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(config.requests, config.window as any),
prefix: 'ratelimit:custom',
});
const { success } = await limiter.limit(identifier);
return !success; // Returns true if rate limited
}
// Middleware helper
export async function checkRateLimit(
request: Request,
limiter: keyof typeof rateLimiters
): Promise<{ limited: boolean; remaining: number; reset: number }> {
const ip = request.headers.get('x-forwarded-for') || 'unknown';
const { success, remaining, reset } = await rateLimiters[limiter].limit(ip);
return {
limited: !success,
remaining,
reset,
};
}
4. RLS Policy Generation
Policy Templates
// lib/db/rls-generator.ts
interface RLSPolicyConfig {
tableName: string;
tenantColumn: string;
ownerColumn?: string;
publicRead?: boolean;
staffOnlyWrite?: boolean;
}
export function generateRLSPolicies(config: RLSPolicyConfig): string {
const { tableName, tenantColumn, ownerColumn, publicRead, staffOnlyWrite } = config;
let sql = `
-- Enable RLS on ${tableName}
ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;
-- Force RLS for table owner
ALTER TABLE ${tableName} FORCE ROW LEVEL SECURITY;
`;
// Read policy
if (publicRead) {
sql += `
-- Public read access
CREATE POLICY "${tableName}_public_select" ON ${tableName}
FOR SELECT
USING (true);
`;
} else {
sql += `
-- Tenant-scoped read
CREATE POLICY "${tableName}_tenant_select" ON ${tableName}
FOR SELECT
USING (
${tenantColumn} IN (
SELECT tenant_id FROM profiles WHERE id = auth.uid()
)
);
`;
}
// Insert policy
if (staffOnlyWrite) {
sql += `
-- Staff-only insert
CREATE POLICY "${tableName}_staff_insert" ON ${tableName}
FOR INSERT
WITH CHECK (
is_staff_of(${tenantColumn})
);
`;
} else {
sql += `
-- Tenant-scoped insert
CREATE POLICY "${tableName}_tenant_insert" ON ${tableName}
FOR INSERT
WITH CHECK (
${tenantColumn} IN (
SELECT tenant_id FROM profiles WHERE id = auth.uid()
)
);
`;
}
// Update policy
if (ownerColumn) {
sql += `
-- Owner or staff can update
CREATE POLICY "${tableName}_update" ON ${tableName}
FOR UPDATE
USING (
${ownerColumn} = auth.uid()
OR is_staff_of(${tenantColumn})
)
WITH CHECK (
${ownerColumn} = auth.uid()
OR is_staff_of(${tenantColumn})
);
`;
} else {
sql += `
-- Staff-only update
CREATE POLICY "${tableName}_staff_update" ON ${tableName}
FOR UPDATE
USING (is_staff_of(${tenantColumn}))
WITH CHECK (is_staff_of(${tenantColumn}));
`;
}
// Delete policy (usually staff only)
sql += `
-- Staff-only delete
CREATE POLICY "${tableName}_staff_delete" ON ${tableName}
FOR DELETE
USING (is_staff_of(${tenantColumn}));
`;
return sql;
}
// Helper function that should exist in Supabase
export const IS_STAFF_OF_FUNCTION = `
CREATE OR REPLACE FUNCTION is_staff_of(check_tenant_id TEXT)
RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid()
AND tenant_id = check_tenant_id
AND role IN ('vet', 'admin')
);
END;
$$;
`;
5. Input Sanitization
Sanitization Utilities
// lib/security/sanitize.ts
import DOMPurify from 'isomorphic-dompurify';
// Sanitize HTML content (for rich text fields)
export function sanitizeHTML(input: string): string {
return DOMPurify.sanitize(input, {
ALLOWED_TAGS: ['b', 'i', 'u', 'p', 'br', 'ul', 'ol', 'li', 'a', 'strong', 'em'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
});
}
// Sanitize plain text (remove all HTML)
export function sanitizeText(input: string): string {
return DOMPurify.sanitize(input, { ALLOWED_TAGS: [] });
}
// Sanitize for SQL LIKE patterns (prevent injection in search)
export function sanitizeSearchPattern(input: string): string {
return input
.replace(/[%_\\]/g, '\\$&') // Escape SQL wildcards
.replace(/[^\w\s\-áéíóúñÁÉÍÓÚÑ]/g, ''); // Remove special chars except Spanish
}
// Sanitize filename
export function sanitizeFilename(input: string): string {
return input
.replace(/[^a-zA-Z0-9._-]/g, '_')
.replace(/_{2,}/g, '_')
.substring(0, 255);
}
// Sanitize phone number (Paraguay format)
export function sanitizePhone(input: string): string {
return input.replace(/[^\d+]/g, '').substring(0, 15);
}
// Validate and sanitize UUID
export function sanitizeUUID(input: string): string | null {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(input) ? input.toLowerCase() : null;
}
// Prevent path traversal
export function sanitizePath(input: string): string {
return input
.replace(/\.\./g, '')
.replace(/^\/+/, '')
.replace(/\/+$/, '');
}
6. Security Headers Middleware
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Security headers
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
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=()');
// Content Security Policy
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https: blob:",
"font-src 'self' data:",
"connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.resend.com",
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'self'",
].join('; ');
response.headers.set('Content-Security-Policy', csp);
// HSTS (only in production)
if (process.env.NODE_ENV === 'production') {
response.headers.set(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload'
);
}
return response;
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico).*)',
],
};
7. Security Checklist
Pre-Deployment Checklist
## Authentication
- [ ] All API routes check authentication
- [ ] Session tokens are httpOnly and secure
- [ ] Logout invalidates server-side session
- [ ] Password requirements enforced (min 8 chars, complexity)
## Authorization
- [ ] Role-based access control implemented
- [ ] Tenant isolation verified (RLS policies)
- [ ] Resource ownership checked before mutations
- [ ] Admin functions protected
## Input Validation
- [ ] All inputs validated with Zod schemas
- [ ] File uploads validated (type, size, content)
- [ ] Search patterns sanitized
- [ ] UUIDs validated before database queries
## Rate Limiting
- [ ] Auth endpoints rate limited (10/min)
- [ ] API endpoints rate limited (100/min)
- [ ] File uploads rate limited (20/hour)
- [ ] Sensitive operations rate limited (5/min)
## Data Protection
- [ ] Sensitive data encrypted at rest
- [ ] PII logged minimally
- [ ] Database backups encrypted
- [ ] Environment variables secured
## Headers & Transport
- [ ] HTTPS enforced
- [ ] Security headers set (CSP, HSTS, etc.)
- [ ] CORS configured correctly
- [ ] Cookies set with secure flags
## Logging & Monitoring
- [ ] Security events logged
- [ ] Failed auth attempts tracked
- [ ] Anomaly detection in place
- [ ] Audit trail for sensitive operations
Reference: OWASP Top 10, Next.js Security documentation, Supabase Security best practices