The boilerplate implements comprehensive security measures following OWASP guidelines, including rate limiting, input sanitization, CSRF protection, and security headers.
CSRF Protection
Double Submit Cookie pattern with Origin/Referer validation.
Rate Limiting
Upstash Redis or in-memory with pre-configured limiters.
Input Sanitization
XSS prevention with DOMPurify, SQL injection protection, path traversal blocking.
Security Headers
CSP, HSTS, X-Frame-Options, and more via next.config.ts.
Env Validation
Zod-based environment variable validation with type safety.
AI Credit Accounting
1 credit = 1 LLM token. Pre-flight check, post-stream decrement of actual tokens.
File Structure
lib/security/ ├── api-security.ts # API route security wrapper ├── rate-limiter.ts # Rate limiting (Upstash Redis / in-memory) ├── sanitize.ts # Input sanitization utilities ├── csrf.ts # CSRF protection ├── verify-admin.ts # Shared admin verification utility └── index.ts # Re-exports lib/env.ts # Zod-based environment validation next.config.ts # Security headers configuration
API Security Wrapper
The withSecurity wrapper provides comprehensive protection for API routes
with a single function call.
Rate limiting is preconfigured with different tiers: strict (5 requests per minute for sensitive operations), standard (30 per minute for general API calls), relaxed (100 per minute for public endpoints), and specialized limiters for AI and contact form endpoints. The system uses Upstash Redis in production and falls back to in-memory limiting in development.
Shorthand Helpers
Wrap your API route handlers with the appropriate security level from apiSecurity: use public() for unauthenticated endpoints with standard rate limiting, authenticated() for endpoints requiring a logged-in user, admin() for platform admin endpoints, ai() for AI endpoints with stricter limits, or webhook() for external webhook receivers.
Rate Limiting
Rate limiting uses Upstash Redis when configured, with automatic fallback to in-memory limiting for development or single-instance deployments.
| Limiter | Limit | Window | Use Case |
|---|---|---|---|
strict |
5 requests | 1 minute | Auth, password reset, sensitive ops |
standard |
30 requests | 1 minute | General API endpoints |
relaxed |
100 requests | 1 minute | Read-heavy endpoints, public data |
ai |
10 requests | 1 minute | AI streaming, expensive operations |
contact |
3 requests | 1 minute | Contact forms, newsletter signup |
webhook |
100 requests | 1 minute | Stripe webhooks, external callbacks |
Client IP identity is deliberately conservative. The rate limiter trusts Cloudflare's CF-Connecting-IP header only when TRUST_CLOUDFLARE_IP=true; enable that only when direct origin access is blocked or your proxy strips spoofed client headers. Otherwise the app ignores spoofable Cloudflare-looking headers and falls back to the last X-Forwarded-For entry, then X-Real-IP.
Input Sanitization
Always sanitize user input using the built-in sanitization utilities. The library provides methods for HTML content (strips tags and scripts), email validation, URL validation, slug generation (alphanumeric and hyphens only), and filename sanitization. These are located in lib/security/sanitize.ts.
DOMPurify HTML Sanitization
For rendering CMS content or user-generated HTML, the boilerplate uses isomorphic-dompurify
with a strict allowlist of safe tags and attributes.
Why DOMPurify?
DOMPurify removes all malicious code including <script> tags, event handlers
(onclick, onerror), and javascript: URLs. The isomorphic-dompurify
package works in both server-side and client-side rendering contexts.
Form Validation
All form inputs are validated on the server side using Zod schemas before processing. Define a schema for each form's expected data, then use safeParse() to validate the request body. Invalid inputs return a 400 response with descriptive error messages. Client-side validation is also applied for immediate user feedback, but never relied upon as the sole validation layer.
CSRF Protection
CSRF protection uses the Double Submit Cookie pattern combined with Origin/Referer header validation.
CSRF is enforced automatically inside every apiSecurity.* / withSecurity() wrapper for POST/PUT/PATCH/DELETE — there is no separate wrapper to add. The standalone withCsrfProtection helper was removed (it was unwired). Client code uses csrfFetch() to attach the Double-Submit token header on client-initiated state changes; the server validates the token + Origin/Referer (against appConfig.url, never the request Host) before the route handler runs.
Security Headers
Security headers are configured in next.config.ts and applied to all routes.
| Header | Value | Purpose |
|---|---|---|
X-Frame-Options |
DENY | Prevent clickjacking |
X-Content-Type-Options |
nosniff | Prevent MIME sniffing |
X-XSS-Protection |
1; mode=block | Enable XSS filter (legacy) |
Referrer-Policy |
strict-origin-when-cross-origin | Control referrer info |
Permissions-Policy |
camera=(), microphone=(), ... | Restrict browser features |
Strict-Transport-Security |
max-age=31536000; includeSubDomains; preload | Force HTTPS (production only) |
Content-Security-Policy |
(see below) | Control resource loading |
Content Security Policy
The CSP is built per-request in lib/security/csp.ts using a cryptographically random nonce + 'strict-dynamic' in production. The static next.config.ts headers no longer carry the Content-Security-Policy entry — the middleware (proxy.ts) injects the dynamic policy. The policy allows resources from trusted domains (Supabase, Stripe, Google Analytics, Google Ads, GTM, Meta Pixel, your active email provider, Crisp, Cloudflare Turnstile, Twemoji flag SVGs from cdnjs) while blocking everything else. If you add a new third-party service, update the appropriate allowlist constant in lib/security/csp.ts rather than the legacy next.config.ts.
The CSP is assembled from six allowlist constants in lib/security/csp.ts — one per directive. The table below summarizes each allowlist; treat lib/security/csp.ts as the authoritative source and edit there when adding a new third party.
| Allowlist (directive) | Origins |
|---|---|
SCRIPT_CDN_ALLOWLIST (script-src) |
Stripe (js.stripe.com), Google (GTM, GA, googleadservices, doubleclick, googlesyndication), Meta/Facebook (connect.facebook.net), X/Twitter (static.ads-twitter.com), Crisp (client.crisp.chat), Cloudflare Turnstile (challenges.cloudflare.com), Twemoji CDN (cdnjs.cloudflare.com) |
STYLE_CDN_ALLOWLIST (style-src) |
Crisp, Google Fonts CSS (fonts.googleapis.com), cdnjs.cloudflare.com. Inline styles also allowed (Next.js RSC / CSS-in-JS compromise) |
IMG_CDN_ALLOWLIST (img-src) |
Supabase Storage (*.supabase.co), Google (GTM, GA, googleadservices, doubleclick, googlesyndication, google.com, *.googleusercontent.com), Meta (facebook.com), X/Twitter (t.co, analytics.twitter.com), Crisp (client/image/storage.crisp.chat), Gravatar (www.gravatar.com), Twemoji flag SVGs (cdnjs.cloudflare.com) |
FONT_CDN_ALLOWLIST (font-src) |
Crisp, Google Fonts files (fonts.gstatic.com), Facebook CDN (static.xx.fbcdn.net) |
CONNECT_CDN_ALLOWLIST (connect-src) |
Supabase REST + Realtime (https://*.supabase.co, wss://*.supabase.co), Stripe API (api.stripe.com), Google (GTM, GA, googleadservices, doubleclick, googlesyndication, analytics.google.com, stats.g.doubleclick.net), Meta (facebook.com, graph.facebook.com), X/Twitter (static.ads-twitter.com, analytics.twitter.com, t.co), Crisp HTTPS + WebSockets (client.crisp.chat, wss://client.relay.crisp.chat, wss://stream.relay.crisp.chat), Upstash Redis (*.upstash.io), Cloudflare Turnstile |
FRAME_CDN_ALLOWLIST (frame-src) |
Stripe (js.stripe.com, hooks.stripe.com), Google Ads conversion frames (td.doubleclick.net), Crisp game frame (game.crisp.chat), Cloudflare Turnstile, YouTube (youtube.com, www.youtube-nocookie.com), Vimeo (player.vimeo.com) |
The middleware adds NEXT_PUBLIC_SUPABASE_URL to connect-src at runtime via the extraConnectOrigins argument so per-project URLs don't need to be hardcoded.
Bot Protection (Cloudflare Turnstile)
Cloudflare Turnstile protects forms from automated abuse. Set NEXT_PUBLIC_TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY from your Cloudflare dashboard. The Turnstile component renders the invisible challenge widget on protected forms (contact, newsletter, login). Server-side validation in the API routes verifies the token with Cloudflare before processing the form submission. In development mode, Turnstile validation is bypassed automatically.
Environment Variable Validation
All environment variables are validated at runtime using Zod schemas in lib/env.ts.
This ensures type safety and clear error messages when required variables are missing.
Validation Timing
Environment variables are validated when the module is imported. If validation fails, the application will throw an error with a clear message indicating which variables are missing or invalid.
AI Credit Accounting
1 credit = 1 LLM token. The credit balance literally represents the
number of LLM tokens the account can consume. Both chat (/api/ai/stream)
and RAG (embeddings + similarity search) deduct the exact usage.total_tokens
reported by the provider — no abstract per-chunk or per-search flat rates.
Credit operations must always use the atomic SQL RPC functions to prevent race conditions. Use decrement_credits to deduct credits (with a reason for the audit trail) and add_credits to add credits (with a source identifier). Never update the credits balance column directly.
Pre-flight check, no upfront reserve
Before each LLM call the route checks credits_balance >= aiConfig.minCreditsRequired
(fast 402 if too low) and only debits after the response, with the exact
token count from the provider. The previous reserve-refund pattern was removed in favor
of pure 1:1 token accounting.
Concurrent requests are not blocked at the application layer; the database
CHECK (credits_balance >= 0) constraint is the safety net of last
resort. If a race causes a post-stream decrement to fail, it is logged via
logError (category ai, event decrement_after_stream_failed)
and surfaced in /admin-dashboard/logs.
Admin Verification Utility
A shared utility for verifying admin access in API routes, with auto-fix logic for the admin email setting.
Additional Security Hardening
The boilerplate includes several layers of defense-in-depth beyond the core security middleware:
| Protection | Details |
|---|---|
| Privilege Escalation Prevention | The is_admin flag cannot be set client-side. Admin status is determined server-side by matching the user email against app_settings.admin_email. |
| Open Redirect Protection | Auth callbacks and gtagReportConversion validate redirect URLs against the application origin, blocking external redirect attacks. |
| Locale Validation on Magic Links | The magic link flow validates the locale parameter against the configured locale list before constructing callback URLs, preventing injection via crafted locale values. |
| OTP Verify (no enumeration leak) | POST /api/auth/verify-otp Zod-validates the code against a dynamic regex built from appConfig.auth.otpLength (e.g. ^\d{6}$), strict-rate-limits per IP, and returns a single generic "Invalid or expired code" message for every failure (wrong digits, expired token, unknown email) — no enumeration vector. The endpoint also re-applies the prelaunch allowlist after verifyOtp succeeds (belt-and-suspenders, mirrors the magic-link callback path). |
| XSS Prevention (DOMPurify) | All CMS-rendered content is sanitized with isomorphic-dompurify using a strict tag/attribute allowlist. Covers both server-side and client-side rendering. |
| Timing-Safe Secret Comparison | Job authentication uses crypto.timingSafeEqual for secret comparison, preventing timing-based attacks on the JOBS_SECRET_KEY. |
| SSRF Protection (Webhooks) | Webhook handler URLs are validated before fetch: URL scheme (http/https) and string-pattern checks for common private-IP literals are enforced. For new fetch destinations from user-supplied URLs, prefer DNS-resolution + IP pinning (resolve the hostname, reject if it lands in a private range, then pin the connection to the resolved IP) per anti-pattern D1 in .claude/rules/anti-patterns.md — a hostname-only string compare can be bypassed by attacker-controlled DNS that resolves to 127.0.0.1. |
| IP Spoofing Resistance | Rate limiter trusts CF-Connecting-IP only when TRUST_CLOUDFLARE_IP=true. Keep it disabled unless the origin is actually protected by Cloudflare or a trusted proxy strips spoofed headers. |
| Rate Limiting on Sensitive Routes | Organization deletion (/api/org/schedule-deletion) and invitation routes use strict rate limiting (5/min) to prevent abuse. |
| Debug Endpoint Gating | Debug and diagnostic endpoints are gated to NODE_ENV === 'development' only, never exposed in production builds. |