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 with pre-configured limiters (required in production).
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 — which is mandatory there, see below — and 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. Keys are isolated by HTTP method and canonical route; dynamic handlers must declare a stable rateLimitScope such as /api/admin/jobs/[jobId] so concrete IDs cannot create unbounded buckets.
Every route.ts needs a wrapper — not just the ones under app/api/. A Next.js route handler is defined by its filename, so page-adjacent handlers such as app/[locale]/(auth)/callback/route.ts and the referral/affiliate tracking redirects are HTTP entry points exactly like an API route. pnpm run audit:api-patterns scans all of app/ and fails the build on any unwrapped handler; the separate 250-LoC ceiling stays scoped to app/api/**, which is where that thinness convention applies.
Rate Limiting
Rate limiting uses Upstash Redis, with in-memory limiting in development.
Upstash is required in production and the app refuses to boot without it —
the in-memory store is per-instance, so behind a load balancer a caller is spread across
every replica's separate counter. That does not loosen the limits, it removes them, and it
looks identical to a working configuration from outside. On a deliberately single-instance
deployment, where per-instance counting is correct, set ALLOW_INMEMORY_RATE_LIMIT=true
to say so explicitly.
Public endpoints are limited before the request body is read. The body-size
guard streams up to maxBodySize (10 MB on /api/documents) to enforce
the limit on the real payload rather than trusting Content-Length, so running it
first would let a throttled caller still make the server ingest the whole body and only pay
the 429 afterwards. Authenticated endpoints are limited after getUser() instead
— that ordering is what stops IP rotation from multiplying one user's quota. The two
orderings are deliberate and should not be unified.
Endpoints that send mail carry a per-recipient budget on top of the IP tier.
An IP limit bounds nothing when the target of the abuse is somebody else's inbox, so
/api/auth/magic-link and /api/auth/verify-otp each additionally limit on the
normalised email address. This matters twice over for the magic link: Supabase's
generateLink creates the auth.users row for an address that has never
signed up, so an unbudgeted send endpoint is also a mass account-registration endpoint.
| 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.
Dependency & Supply-Chain Security
This repository accepts pnpm 11.20.0 only. npm, npx, pnpx, pnpm dlx, Bun, Yarn, and their lockfiles are forbidden. Add a reviewed CLI as an exact devDependency and invoke it with pnpm exec; never fetch and execute a package ephemerally.
Before every release, run pnpm install --frozen-lockfile, pnpm run audit:signatures, and pnpm run audit:security. The moderate-or-higher vulnerability baseline is zero.
Dependency hooks are denied by default
ignore-scripts=true disables dependency lifecycle scripts. pnpm can run install/build
hooks only for the exact package names reviewed in pnpm-workspace.yaml under
allowBuilds. Never replace that list with a wildcard or blanket approval; review it
again whenever a permitted package changes version.
The workspace also applies a 7-day release quarantine, strict publication-time lookup, registry trust checks that reject integrity downgrades, fresh registry integrity verification instead of trusting the lockfile alone, and a block on exotic transitive dependency sources. Exceptions are exact reviewed package versions: either their integrity matched the previous lockfile, or they are urgent signed security fixes. Do not broaden them.
Never use a forced or automatic audit-fix command. Upgrade the direct dependency first; if no acceptable upgrade exists, add the narrowest possible override in pnpm-workspace.yaml, inspect the resulting pnpm-lock.yaml diff, then rerun signature, vulnerability, and behavior tests.
isomorphic-dompurify is held at 2.x on purpose: although Node 24.18.1 satisfies the v3 engine requirement (^22.22.2 || ^24.15.0 || >=26), a sanitizer major upgrade requires a separate review and targeted rendering tests. The 2.x line still resolves the fully patched dompurify in range. The repository pins Node 24.18.1 LTS in version files, package engines, CI, and Docker.
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 (obsolete; kept for old browsers — frame-ancestors in the CSP is the control modern browsers consult, and it wins where both are present) |
Cross-Origin-Opener-Policy |
same-origin-allow-popups | Severs the window-handle relationship with cross-origin documents (XS-Leaks, tabnabbing) — a surface neither X-Frame-Options nor frame-ancestors covers, since those govern framing rather than window references. The -allow-popups variant keeps OAuth/Stripe popup flows working. |
Cross-Origin-Resource-Policy |
same-origin (on /api/* only) |
Refuses cross-origin no-cors loads of API responses. Scoped to the API deliberately: a global value would also block social crawlers and legitimate embedders from fetching public OG images. |
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 production CSP has two deliberate modes. Dynamic and private routes receive a cryptographically random nonce plus 'strict-dynamic'. Statically generated public routes receive a deterministic policy so ISR and CDN caching remain possible; Next.js currently emits inline RSC bootstrap scripts on those pages, so that mode includes 'unsafe-inline' even when external build assets carry SRI. React escaping and the DOMPurify boundary in components/html-renderer.tsx are therefore mandatory defenses for CMS content, and unsanitized dangerouslySetInnerHTML must never be introduced on a static route. The middleware (proxy.ts) selects the mode and injects the policy.
The policy allows resources from trusted domains (Supabase, Stripe, Google Analytics, Google Ads, GTM, Meta Pixel, your active email provider, Crisp, Cloudflare Turnstile, and Twemoji flag SVGs from cdnjs — the last of these in img-src only) 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.
script-srccdnjs.cloudflare.com was removed from the script allowlist. Listing a library CDN as a script source means "any JavaScript anyone has ever published there", which is a complete CSP bypass for an attacker with an HTML-injection foothold — on exactly the browsers the allowlist exists to serve, since modern ones ignore it under 'strict-dynamic'. It was only ever needed for flag SVGs, which is an img-src need. Apply the same reasoning before adding any new host to SCRIPT_CDN_ALLOWLIST.
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: the scheme must be http/https, the hostname is resolved via DNS and rejected if any returned address falls in a private range, and the runtime fetch pins the connection to the validated IP through an undici agent so the DNS-rebinding window is closed. Use the same validatePublicWebhookUrl() helper for any new fetch to a user-supplied URL rather than a hostname string compare, which attacker-controlled DNS defeats trivially (anti-pattern D1).IPv6 is judged numerically, not as text. A single IPv4 host has several IPv6 spellings that all reach it — ::ffff:127.0.0.1, ::ffff:7f00:1 (the same address in hex), ::127.0.0.1, NAT64 64:ff9b::7f00:1, and 6to4 2002:7f00:1::. A regex written for the dotted form recognises one of them and passes the rest as public. Addresses are therefore parsed into numeric hextets and any embedded IPv4 is resolved and judged as IPv4. Regression suite: __tests__/security/ssrf.test.ts. |
| 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. |