Nonce-based CSP in Middleware and the CDN cache invalidation trap (a cached nonce is not a nonce), SSRF via next/image remotePatterns wildcard misconfiguration, SSRF via Server Actions, secret leakage with server-only and taint API, and rate limiting: edge vs application layer.
A-13 — Security Architecture
Who this is for: Architects who need to think about the attack surface of a Next.js application — not just "add a Content Security Policy" but understanding what's uniquely exposed by server-side rendering, what Server Actions change about the CSRF threat model, how secrets leak through the RSC boundary, and the layered defence strategy that holds up under real-world attack conditions.
The Next.js Attack Surface Map
Before defending, map what's exposed. A deployed Next.js application has a larger attack surface than a static site or a pure API server:
Attack Surface
├── Public routes (anyone can hit these)
│ ├── Static HTML pages — served from CDN, no attack surface beyond the HTML
│ ├── Route Handlers (GET) — same as any REST API
│ ├── Server Actions — POST endpoints with action IDs
│ └── Images, fonts, JS bundles — static assets
├── Authenticated routes
│ ├── Server Components — execute server code per-request
│ ├── Route Handlers (POST/PUT/DELETE) — mutation endpoints
│ └── Server Actions — mutation endpoints with framework-level CSRF protection
├── Edge layer
│ ├── Middleware — runs on every request
│ └── Edge Route Handlers — lightweight compute at CDN
└── Build-time exposure
├── Source maps — optional, potentially exposes logic
└── Environment variable leakage through client bundles
The unique risk in Next.js is the blurred server/client boundary. Code that looks like frontend code runs on the server. Data fetched for rendering might contain more than it should. The defences must address both.
Environment Variable Leakage
The most common security mistake in Next.js: accidentally including server-only secrets in the client bundle.
Any environment variable prefixed with NEXT_PUBLIC_ is baked into the client JavaScript bundle at build time. Anyone can extract it from the bundle. This is by design — it's for public API keys, not secrets.
The problem is that engineers forget and use process.env.DATABASE_URL or process.env.STRIPE_SECRET_KEY in a Client Component. TypeScript doesn't catch this. The variable is simply undefined on the client... unless the Next.js build includes it (it doesn't by default, but developer error is possible).
Three layers of defence:
Layer 1: server-only package
ts// lib/db.ts import 'server-only'; // If this file is imported by a Client Component, the build throws: // "Error: You're importing a component that needs server-only..."
Layer 2: taint API (from A-1 — marking values that must never reach the client):
tsimport { experimental_taintUniqueValue as taintUniqueValue } from 'react'; taintUniqueValue( 'Do not pass API keys to Client Components', process, process.env.STRIPE_SECRET_KEY! );
Layer 3: NEXT_PUBLIC_ discipline — audit any NEXT_PUBLIC_ variable before it's added. These are intentionally public. If someone adds NEXT_PUBLIC_DATABASE_URL, that's a critical security incident.
Automated check in CI:
bash# Check for non-NEXT_PUBLIC_ env vars referenced in client components # (rough grep — replace with a proper lint rule for production) grep -r "process.env" src --include="*.tsx" --include="*.ts" | \ grep -v "NEXT_PUBLIC_" | \ grep -v "server-only" | \ grep -v "// server"
Content Security Policy
A Content Security Policy (CSP) is an HTTP response header that tells browsers which resources they're allowed to load. It's the primary defence against Cross-Site Scripting (XSS) — even if an attacker injects a <script> tag, the browser refuses to execute it if it's not from an allowed source.
Next.js's streaming architecture complicates CSP: the streaming runtime injects inline <script> tags to swap Suspense content. A strict CSP (script-src 'self') blocks these scripts and breaks the page.
The solution is nonce-based CSP:
ts// middleware.ts import { NextRequest, NextResponse } from 'next/server'; export function middleware(request: NextRequest) { const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); const csp = [ `default-src 'self'`, `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`, `style-src 'self' 'nonce-${nonce}'`, `img-src 'self' blob: data: https:`, `font-src 'self'`, `object-src 'none'`, `base-uri 'self'`, `form-action 'self'`, `frame-ancestors 'none'`, `upgrade-insecure-requests`, ].join('; '); const response = NextResponse.next({ request: { headers: new Headers({ ...Object.fromEntries(request.headers.entries()), 'x-nonce': nonce, }), }, }); response.headers.set('Content-Security-Policy', csp); return response; }
tsx// app/layout.tsx — use the nonce from Middleware for inline scripts import { headers } from 'next/headers'; export default async function RootLayout({ children }: { children: React.ReactNode }) { const nonce = (await headers()).get('x-nonce') ?? ''; return ( <html> <body> {children} {/* Next.js automatically uses the nonce for its streaming scripts */} </body> </html> ); }
Pass the nonce to Next.js via the next.config.ts:
tsconst config: NextConfig = { experimental: { nonce: 'auto', // instructs Next.js to read the nonce from the x-nonce header }, };
The 'strict-dynamic' in script-src allows scripts loaded by already-trusted scripts (your app's own bundles loading their chunks), preventing the need to whitelist every CDN URL.
CSRF — The Server Action Story
Classic CSRF attacks work by tricking a user's browser into making a cross-origin request that includes their session cookies. For example: a malicious page that includes a form that POSTs to bank.com/transfer.
Server Actions have built-in CSRF protection through two mechanisms:
-
SameSitecookies. Auth.js v5 and Next.js's own cookie handling default toSameSite=LaxorSameSite=Strict. Cross-origin form submissions don't include the session cookie, so the action can't authenticate. -
Originheader validation. Next.js checks theOriginheader on Server Action requests and rejects those originating from different domains.
These protections cover the common CSRF vectors. Where you're still responsible:
- Custom Route Handlers — Next.js provides no automatic CSRF protection for Route Handlers. If you have
POST /api/transfer, add your own CSRF token validation. SameSite=Nonecookies — required for third-party contexts (e.g., an iframe). These are vulnerable to CSRF by definition. Use explicit CSRF tokens.- Subdomain attacks — if
evil.example.comis attacker-controlled and you useSameSite=Lax, subdomains can send requests that include cookies forexample.com.
SQL Injection and Prisma
Prisma's query API is parameterised by design — user input passed as arguments to Prisma queries is always escaped. Standard Prisma usage is not vulnerable to SQL injection.
The vulnerability is in raw queries:
ts// ❌ SQL injection — user input concatenated into query string const products = await db.$queryRaw( `SELECT * FROM products WHERE category = '${userInput}'` ); // ✅ Parameterised — Prisma escapes userInput const products = await db.$queryRaw` SELECT * FROM products WHERE category = ${userInput} `; // Note: template literal syntax, not string concatenation
The template literal syntax for $queryRaw is the safe version. It looks similar to string interpolation but Prisma intercepts the values and parameterises them properly.
Security Headers — The Baseline
Every Next.js production application should set these response headers:
ts// next.config.ts const securityHeaders = [ { key: 'X-DNS-Prefetch-Control', value: 'on' }, { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }, { key: 'X-Frame-Options', value: 'DENY' }, { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, ]; const config: NextConfig = { async headers() { return [ { source: '/(.*)', headers: securityHeaders, }, ]; }, };
What each header does:
- HSTS — tells browsers to always use HTTPS for this domain, even if the user types HTTP
- X-Frame-Options: DENY — prevents clickjacking (your page in an iframe)
- X-Content-Type-Options: nosniff — prevents MIME-type sniffing attacks
- Referrer-Policy — controls what's sent in the Referer header (prevents leaking URLs)
- Permissions-Policy — denies access to browser APIs your app doesn't need
Input Sanitisation for Rich Text
If your application accepts HTML from users (rich text editors, markdown with HTML allowed), sanitise before rendering:
tsximport DOMPurify from 'isomorphic-dompurify'; // Server Component — sanitise server-side export function UserContent({ html }: { html: string }) { const clean = DOMPurify.sanitize(html, { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li'], ALLOWED_ATTR: ['href', 'target'], ALLOW_DATA_ATTR: false, }); return <div dangerouslySetInnerHTML={{ __html: clean }} />; }
The sanitisation must happen before dangerouslySetInnerHTML. Sanitising only on the client is insufficient — SSR renders the HTML server-side first.
Dependency Auditing
External packages are part of your attack surface. A supply chain attack through a compromised npm package can exfiltrate environment variables, secrets, or user data.
bash# Regular audit npm audit # Fix automatically (patch-level only) npm audit fix # Check for known vulnerabilities with a stricter threshold in CI npm audit --audit-level=high
The CI check:
yaml- name: Security audit run: npm audit --audit-level=high # Fails the build if any high/critical vulnerabilities are found
For applications where dependencies are a primary concern (fintech, healthcare), tools like socket.dev or Snyk provide deeper analysis: detecting suspicious package behaviour, not just known CVEs.
Where We Go From Here
A-14 covers the deployment infrastructure choices that go beyond Vercel — self-hosted with Docker and Kubernetes, WebSockets and long-lived connections, and when serverless is the wrong choice. After hardening the application in A-13, A-14 addresses the infrastructure it runs on.
The CSP Nonce Cache Conflict
Nonce-based CSP works by generating a unique random value per request, embedding it in the <script nonce="..."> tag, and including it in the Content-Security-Policy header. A script only executes if its nonce attribute matches the one in the header. Since the nonce is different for every request, an XSS attacker can't predict it and can't inject scripts that will execute.
The implementation in Middleware is correct:
ts// middleware.ts export function middleware(req: NextRequest) { const nonce = Buffer.from(crypto.randomUUID()).toString('base64') const csp = `default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic';` const response = NextResponse.next({ request: { headers: new Headers({ ...req.headers, 'x-nonce': nonce }) }, }) response.headers.set('Content-Security-Policy', csp) return response }
The problem: if that page is served from a CDN cache, the nonce is no longer unique per request. It's the nonce that was generated when the page was first cached — the same value for every user who receives the cached response.
A static nonce is not a nonce. It's a constant. An attacker who can read the CSP header of one cached response now knows the nonce value for every user receiving that response. The entire security guarantee collapses.
This failure mode doesn't appear in local development (no CDN caching) and doesn't appear on a fresh Vercel deployment of a fully dynamic app. It appears when:
- You have a CDN in front of your Next.js server
- The CDN caches pages (via
Cache-Control: publicor default caching rules) - Those pages use nonce-based CSP
Fix 1: Force Cache-Bypass for Nonce-Protected Pages
The most direct fix: any page that uses a nonce must not be cached at the CDN layer.
ts// middleware.ts export function middleware(req: NextRequest) { const nonce = Buffer.from(crypto.randomUUID()).toString('base64') const csp = `default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic';` const response = NextResponse.next({ request: { headers: new Headers({ ...req.headers, 'x-nonce': nonce }) }, }) response.headers.set('Content-Security-Policy', csp) // Force private, no CDN caching for nonce-protected responses response.headers.set('Cache-Control', 'private, no-store') return response }
Cache-Control: private, no-store instructs CDNs to never cache the response. Every request hits the origin, every response gets a fresh nonce. The downside: you lose CDN caching for all pages covered by this Middleware.
For applications where most pages are dynamic (authenticated dashboards, personalised feeds), this is acceptable. For content-heavy sites where static caching is critical, use Fix 2.
Fix 2: Hash-Based CSP for Cacheable Pages
For static or ISR pages that benefit from CDN caching, nonce-based CSP is architecturally wrong — you can't have both a unique-per-request nonce and a cached response. Use hash-based CSP instead.
Hash-based CSP lists the SHA-256 hash of each allowed inline script. Unlike nonces, hashes are deterministic — the same script always produces the same hash. CDN-cached responses with hash-based CSP remain valid.
ts// At build time, compute hashes for your inline scripts import crypto from 'crypto' const inlineScript = `window.__ENV = ${JSON.stringify(publicConfig)};` const hash = crypto.createHash('sha256').update(inlineScript).digest('base64') // hash: "sha256-abc123..." // next.config.ts const csp = [ "default-src 'self'", `script-src 'self' 'sha256-${hash}' 'strict-dynamic'`, "style-src 'self' 'unsafe-inline'", ].join('; ')
The downside: you must know the exact content of every inline script at build time. Any dynamic inline script (one whose content varies at runtime) cannot use hash-based CSP — use nonces for those pages and forfeit CDN caching.
The Hybrid Model
Real applications use both:
- Dynamic pages (authenticated, personalised): nonce-based CSP +
Cache-Control: private, no-store - Static/ISR pages (marketing, content): hash-based CSP +
Cache-Control: public, max-age=3600
Apply CSP in Middleware and branch on whether the request is for a dynamic or static route:
tsexport function middleware(req: NextRequest) { const isDynamicRoute = DYNAMIC_ROUTES.some(r => req.nextUrl.pathname.startsWith(r)) if (isDynamicRoute) { // Nonce-based, no CDN caching const nonce = Buffer.from(crypto.randomUUID()).toString('base64') const res = NextResponse.next({ request: { headers: { 'x-nonce': nonce } } }) res.headers.set('Content-Security-Policy', buildNonceCSP(nonce)) res.headers.set('Cache-Control', 'private, no-store') return res } // Hash-based, CDN-cacheable const res = NextResponse.next() res.headers.set('Content-Security-Policy', STATIC_PAGE_CSP) return res }
SSRF via next/image — The Open Proxy Misconfiguration
next/image optimises remote images by proxying them through /_next/image?url=.... The url parameter is the address of the image to fetch. Next.js fetches that URL server-side, optimises it, and returns it to the client.
This is a Server-Side Request Forgery (SSRF) vector. If the remotePatterns configuration is too permissive, an attacker can use your image optimisation endpoint to make arbitrary HTTP requests from your server.
The dangerous pattern:
ts// next.config.ts const config: NextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: '**', // 🚨 allows ANY hostname }, ], }, }
With this configuration:
bash# Attacker makes your server fetch internal AWS metadata curl "https://yourapp.com/_next/image?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/&w=100&q=75" # Your server fetches the AWS instance metadata endpoint # The response (JSON with IAM credentials) is returned to the attacker
The AWS instance metadata endpoint (169.254.169.254), internal Kubernetes services (kubernetes.default.svc), internal Redis instances, and other services accessible from your server's network are all reachable through an unrestricted next/image SSRF.
The ** wildcard is not safe. It's frequently copied from documentation examples meant for development use. In production, it's an open SSRF proxy.
Fix: Restrict remotePatterns to Exact Domains
ts// next.config.ts const config: NextConfig = { images: { remotePatterns: [ // Exact domains only — no wildcards { protocol: 'https', hostname: 'images.unsplash.com', }, { protocol: 'https', hostname: 'cdn.yourapp.com', }, { protocol: 'https', hostname: '**.cloudinary.com', // subdomain wildcard for cloudinary only }, ], }, }
Subdomain wildcards (**.hostname.com) are acceptable — they restrict to a specific domain family. Top-level wildcards (**) are not.
Additional SSRF Mitigations
Block private IP ranges at the network layer. Configure your firewall or security group to block outbound connections from your Next.js server to RFC1918 addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and the link-local range (169.254.0.0/16). This way, even if remotePatterns is misconfigured, the network blocks the request.
Disable next/image remote optimisation if you don't need it. If all your images are self-hosted or served from a fixed CDN, set unoptimized: true or don't configure remotePatterns at all — the endpoint returns 400 for unlisted domains by default.
Rotate credentials if you suspect exploitation. If you shipped a ** wildcard to production on AWS or GCP, assume the instance metadata endpoint was hit. Rotate your IAM credentials and audit your CloudTrail logs for unusual API calls from the instance.