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.
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:
text
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
Layer 2: taint API (from A-1 — marking values that must never reach the client):
ts
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
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
tsx
Pass the nonce to Next.js via the next.config.ts:
ts
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:
SameSite cookies. Auth.js v5 and Next.js's own cookie handling default to SameSite=Lax or SameSite=Strict. Cross-origin form submissions don't include the session cookie, so the action can't authenticate.
Origin header validation. Next.js checks the Origin header 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=None cookies — 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.com is attacker-controlled and you use SameSite=Lax, subdomains can send requests that include cookies for example.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
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
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)
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:
tsx
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
The CI check:
yaml
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
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: public or 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
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
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.
Apply CSP in Middleware and branch on whether the request is for a dynamic or static route:
ts
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
With this configuration:
bash
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
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.
SSRF via Server Actions — The Vector Nobody Threat-Models
The next/image SSRF above gets attention because it's a well-known misconfiguration with a documented fix. The Server Action version of the same bug gets almost none, because it doesn't look like a security-sensitive code path — it looks like a normal feature.
Any Server Action that takes a URL as input and fetches it server-side is an SSRF vector. "Preview this link," "verify this webhook responds," "fetch this RSS feed," "check if this image URL is valid" — all of these are, mechanically, the same thing as the next/image proxy: your server, not the user's browser, is making an outbound HTTP request to an address the user controls.
The vulnerable pattern:
ts
This looks completely reasonable in a PR. It's a link preview feature — every product has one. The problem is that fetch(url) runs on your server, with your server's network access, and the user supplies url:
bash
None of these addresses are reachable from the public internet. That's the point of putting them there. But your Next.js server can reach them, because it's running inside the same network — and the Server Action just became the attacker's tunnel in.
The fix: allowlist, or at minimum block the dangerous ranges.
The strongest fix is a domain allowlist, the same principle as remotePatterns for next/image:
ts
When the feature genuinely needs to accept arbitrary domains (a general-purpose "preview any link" tool can't allowlist domains it's never seen), fall back to blocking the dangerous ranges instead — private/link-local IPs and the cloud metadata address specifically:
ts
The allowlist approach is strictly safer when it's an option — an IP-range blocklist has to anticipate every way a hostname can resolve to something internal (including DNS rebinding, where a hostname resolves to a public IP at validation time and a private one at fetch time), and it's easy to miss one. Reach for the blocklist only when the feature's whole purpose is fetching arbitrary user-supplied domains.
Rate Limiting — Edge vs. Application Layer
A Server Action or Route Handler with no rate limiting is an open invitation: credential-stuffing against a login action, an expensive database-backed search hit thousands of times a second, a webhook endpoint hammered until it falls over. The question isn't whether to rate limit — it's where.
Edge / Middleware rate limiting runs in Middleware, ahead of your route or Server Action:
ts
The upside: it rejects abusive requests before they touch your route handler, your database connection pool, or your Server Action logic — the cheapest possible place to say no. The cost has already been paid by the time application-layer rate limiting would reject the same request.
The constraints: Middleware runs on the Edge runtime, which means no arbitrary Node.js APIs (see the CSP nonce fix above — this is the same runtime, the same limitation). You can't use an in-memory Map to track request counts either, because Edge Middleware isn't guaranteed to run on the same instance between requests — you need an edge-compatible store like Upstash Redis or Vercel KV that's reachable over HTTP/fetch from any edge location.
Application-layer rate limiting runs inside the Server Action or Route Handler itself:
ts
The upside: full Node.js APIs, full access to your session/auth context, and the ability to key the limit on things Middleware doesn't easily see — the authenticated user ID, their subscription tier, the specific action they're calling rather than just the route. The cost: by the time you reject the request, it's already been through routing, authentication, and whatever middleware ran before it — more resources spent per rejected request than an edge-layer block. It also only protects the routes that actually call your rate-limiting code; a Route Handler you forgot to wrap gets no protection at all, whereas Middleware's matcher covers everything that matches the pattern regardless of what the handler does internally.
A realistic recommendation: layer both, and give each layer a job it's good at.
Edge Middleware: coarse, cheap, IP-based limits applied broadly — e.g. no more than N requests per IP per 10 seconds across all API routes. This is your defence against blunt-force abuse (scripted scraping, credential-stuffing bursts, a misbehaving client retrying in a loop) and it stops the request before it costs you anything.
Application layer: fine-grained, per-user or per-action limits that need session context Middleware doesn't have — e.g. "this specific user can request at most 3 expensive reports per minute" or "this Server Action can be called at most once per second per authenticated session." This is your defence against a legitimate, authenticated user's client (or account) doing something excessive that a per-IP rule wouldn't catch, especially behind a shared corporate NAT where many legitimate users share one IP.
Neither layer alone covers both failure modes. Edge-only misses the "one authenticated user abusing one specific expensive action" case; application-only lets unauthenticated blunt-force traffic all the way through your stack before rejecting it.
Knowledge Check
Which combination of layered defences best prevents server-side secrets from leaking into Client Components in Next.js?
Why can using a nonce-based Content Security Policy (CSP) be problematic when a Next.js application is deployed behind a CDN?
How can a permissive remotePatterns configuration in next/image lead to a Server-Side Request Forgery (SSRF) vulnerability?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
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
// lib/db.tsimport'server-only';// If this file is imported by a Client Component, the build throws:// "Error: You're importing a component that needs server-only..."
import{ experimental_taintUniqueValue as taintUniqueValue }from'react';taintUniqueValue('Do not pass API keys to Client Components', process, process.env.STRIPE_SECRET_KEY!);
# 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"
// middleware.tsimport{ NextRequest, NextResponse }from'next/server';exportfunctionmiddleware(request: NextRequest){// Web Standard APIs only — crypto.getRandomValues() and btoa() work natively// in the Edge runtime with no Node.js polyfill involved (see the note below).const nonce =btoa(String.fromCharCode(...crypto.getRandomValues(newUint8Array(16))));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:newHeaders({...Object.fromEntries(request.headers.entries()),'x-nonce': nonce,}),},}); response.headers.set('Content-Security-Policy', csp);return response;}
// app/layout.tsx — use the nonce from Middleware for inline scriptsimport{ headers }from'next/headers';exportdefaultasyncfunctionRootLayout({ children }:{ children:React.ReactNode}){const nonce =(awaitheaders()).get('x-nonce')??'';return(<html><body>{children}{/* Next.js automatically uses the nonce for its streaming scripts */}</body></html>);}
const config: NextConfig ={ experimental:{ nonce:'auto',// instructs Next.js to read the nonce from the x-nonce header},};
// ❌ SQL injection — user input concatenated into query stringconst products =await db.$queryRaw(`SELECT * FROM products WHERE category = '${userInput}'`);// ✅ Parameterised — Prisma escapes userInputconst products =await db.$queryRaw` SELECT * FROM products WHERE category = ${userInput}`;// Note: template literal syntax, not string concatenation
importDOMPurifyfrom'isomorphic-dompurify';// Server Component — sanitise server-sideexportfunctionUserContent({ 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<divdangerouslySetInnerHTML={{ __html: clean }}/>;}
# Regular auditnpm audit
# Fix automatically (patch-level only)npm audit fix
# Check for known vulnerabilities with a stricter threshold in CInpm audit --audit-level=high
-name: Security audit
run: npm audit --audit-level=high
# Fails the build if any high/critical vulnerabilities are found
// middleware.tsexportfunctionmiddleware(req: NextRequest){// crypto.getRandomValues() + btoa() — both Web Standard APIs, not Buffer.// Buffer is a Node.js global; the Edge runtime happens to polyfill a partial// version of it, but relying on that polyfill for something as load-bearing// as a security nonce is relying on an implementation detail, not a guarantee.const nonce =btoa(String.fromCharCode(...crypto.getRandomValues(newUint8Array(16))))const csp =`default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic';`const response = NextResponse.next({ request:{ headers:newHeaders({...req.headers,'x-nonce': nonce })},}) response.headers.set('Content-Security-Policy', csp)return response
}
# Attacker makes your server fetch internal AWS metadatacurl"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
// next.config.tsconst 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},],},}
// app/actions.ts'use server';exportasyncfunctionpreviewLink(url:string){// 🚨 Fetches whatever URL the client sends, no validationconst response =awaitfetch(url);const html =await response.text();const title = html.match(/<title>(.*?)<\/title>/)?.[1];return{ title };}
# The client can call this Server Action with any URL, including:# - Cloud metadata endpoints (no auth required from inside the VPC/instance)previewLink('http://169.254.169.254/latest/meta-data/iam/security-credentials/')# - Internal admin panels not exposed to the public internetpreviewLink('http://internal-admin.svc.cluster.local/users')# - Localhost services running alongside your apppreviewLink('http://localhost:6379/') // Redis, if it has no auth
// app/actions.ts'use server';constALLOWED_HOSTNAMES=newSet(['example.com','www.example.com','blog.example.com',]);exportasyncfunctionpreviewLink(rawUrl:string){let url:URL;try{ url =newURL(rawUrl);}catch{thrownewError('Invalid URL');}if(url.protocol !=='https:'){thrownewError('Only HTTPS URLs are allowed');}if(!ALLOWED_HOSTNAMES.has(url.hostname)){thrownewError('This domain is not allowed');}const response =awaitfetch(url,{ redirect:'manual'});// don't silently follow redirects to a blocked hostconst html =await response.text();const title = html.match(/<title>(.*?)<\/title>/)?.[1];return{ title };}
'use server';import{ isIP }from'node:net';constBLOCKED_HOSTNAMES=newSet(['169.254.169.254','metadata.google.internal']);functionisPrivateOrLinkLocal(ip:string):boolean{// IPv4 private ranges (RFC 1918) + link-local (RFC 3927, includes cloud metadata)return(/^10\./.test(ip)||/^192\.168\./.test(ip)||/^172\.(1[6-9]|2\d|3[0-1])\./.test(ip)||/^169\.254\./.test(ip)|| ip ==='127.0.0.1'|| ip ==='::1');}exportasyncfunctionpreviewLink(rawUrl:string){const url =newURL(rawUrl);if(url.protocol !=='https:'){thrownewError('Only HTTPS URLs are allowed');}if(BLOCKED_HOSTNAMES.has(url.hostname)){thrownewError('This host is not allowed');}// Resolving the hostname yourself and checking the resolved IP (rather than// trusting the hostname string) is what actually closes the DNS-rebinding// gap here — a hostname that looks public can still resolve to 169.254.169.254.// A production implementation should resolve via `dns.lookup` and re-check// the IP with isPrivateOrLinkLocal before fetching — verify the exact API// against your runtime (Node.js APIs like `node:dns` aren't available if// this action somehow runs under the Edge runtime).if(isIP(url.hostname)&&isPrivateOrLinkLocal(url.hostname)){thrownewError('This host is not allowed');}const response =awaitfetch(url,{ redirect:'manual'});const html =await response.text();const title = html.match(/<title>(.*?)<\/title>/)?.[1];return{ title };}
// app/actions.ts'use server';import{ auth }from'@/lib/auth';import{ ratelimit }from'@/lib/ratelimit';// Node-compatible client, e.g. ioredis-backedexportasyncfunctionsubmitExpensiveReport(params: ReportParams){const session =awaitauth();if(!session?.user)thrownewError('Unauthorized');// Per-user, per-action limit — only possible here, where you have full auth contextconst{ success }=await ratelimit.limit(`report:${session.user.id}`);if(!success){thrownewError('You are generating reports too quickly. Try again shortly.');}returngenerateReport(params);}