Next.js 16 CSP — Missing script-src Allowed XSS Through a Third-Party Widget
Next.js security best practices: Content Security Policy, Taint API (taint, taintObjectReference), Server Actions security model, and data sanitization explained with production code..
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
- ✓Node.js 18+
- ✓Next.js 14+ (16 recommended)
- ✓Understanding of HTTP headers and browser security model
- ✓Familiarity with React server/client component boundary
- A missing
script-srcdirective in Content-Security-Policy allowed a third-party chatbot widget to inject arbitrary JavaScript, exfiltrating user session cookies via XSS. - Next.js 16’s Taint API (
experimental.taint,taintObjectReference) prevents the most common data leak: passing sensitive server data (API keys, user PII) to a client component. - The Taint API works at the object reference level — once tainted, passing the object to a client component throws a runtime error in development and strips the data in production.
- Server Actions have a specific security model: they cannot be invoked from external origins unless explicitly allowed, but input validation is still your responsibility.
Imagine you have a bouncer (CSP) at the door of your club who checks IDs. If you forget to tell the bouncer which types of IDs are allowed (missing script-src), anyone with any ID can walk in — including a guy with a forged ID (XSS payload) hidden inside a legitimate-looking package (third-party widget). The Taint API is like putting a “DO NOT REMOVE FROM PREMISES” stamp on sensitive documents. If an employee accidentally tries to hand them to a customer (client component), an alarm goes off before the transfer happens.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Security in Next.js 16 is a layered defense: Content Security Policy (CSP) controls what scripts and resources the browser can load, the Taint API prevents accidental data exposure to client components, Server Actions enforce origin-based access control, and React’s built-in sanitization handles most XSS vectors in JSX. But these layers only work if they’re configured correctly.
We learned this when a penetration test revealed that our Content-Security-Policy was missing the script-src directive. This meant the browser would allow any script to execute — including a malicious payload embedded in a third-party chatbot widget that exfiltrated session cookies to an attacker-controlled server. The widget was legitimate, but it dynamically loaded JavaScript from a CDN that an attacker had compromised.
The fix required: adding script-src with specific hash-based allowlisting for inline scripts, enabling the Taint API to prevent server data leaks, and configuring Server Actions with proper input validation. This article covers all three defense layers with production code examples and deployment guidance.
CSP — Why default-src Is Not a Security Blanket
Content-Security-Policy is the browser’s instruction manual for what resources are allowed to load on your page. The default-src directive serves as a fallback for any directive that is NOT explicitly set. Many developers set default-src 'self' and assume all resource types are restricted. This is incorrect.
When script-src is omitted, the browser uses default-src for scripts but with a critical caveat: default-src does not control inline script execution. Inline scripts and event handlers are controlled exclusively by script-src. Without an explicit script-src, browsers default to allowing inline scripts.
The correct approach: always declare script-src explicitly. Use 'strict-dynamic' for hash-based or nonce-based allowlisting of inline scripts, and specify exact origins for third-party script sources.
Hash-Based Script Allowlisting — How to Generate and Manage Script Hashes
Hash-based allowlisting requires generating a SHA-256 hash of every inline script in your application and adding it to the script-src directive. The hash changes when the script content changes, so any tampering produces a different hash and the browser blocks execution.
To generate hashes: extract every inline <script> tag from your production HTML, compute its SHA-256 hash using openssl dgst -sha256 -binary | base64, and add 'sha256-<hash>' to your script-src. For Next.js, inline scripts include the bootstrap runtime, analytics snippets, and embedded data.
The challenge: Next.js adds inline scripts dynamically during build. Each deployment may have different hashes. Solution: use 'strict-dynamic' or generate hashes as part of your CI pipeline.
The Taint API — Preventing Accidental Data Exposure
The Taint API is React 19’s mechanism for preventing sensitive server data from reaching client components. When you taint an object reference or value, any attempt to pass it to a 'use client' component throws an error in development and silently strips the data in production.
This is the solution to the most common Next.js data leak: a server component fetches user data with API keys or PII, then passes it as a prop to a client component. The data gets serialized in the HTML response and becomes visible in the page source. The Taint API catches this at compile time.
The API works at the object reference level: taintObjectReference(message, object) marks a specific object instance. taintUniqueValue is for primitive values like strings and numbers.
Server Actions Security Model — What They Protect and What They Don’t
Server Actions in Next.js 16 have a built-in security model: they are only accessible from the same origin by default. Cross-origin requests are rejected with a 403 Forbidden response. This prevents external websites from calling your server actions directly.
However, Server Actions do NOT validate input by default. If a Server Action expects a userId parameter, a malicious user can pass any user ID. Server Actions are essentially POST endpoints — you must add your own authorization checks, input validation, and rate limiting.
The common mistake: treating Server Actions as secure by default because they’re not exposed as REST endpoints. They ARE exposed as POST requests to internal endpoints. The origin check prevents CSRF but does not prevent a logged-in user from calling any action with any parameters.
XSS Prevention in JSX — React’s Built-In Defenses and Their Limits
React’s JSX automatically escapes string values, preventing the most common XSS vector: injecting HTML via string interpolation. If a user types <script>alert(1)</script> into a rendered expression, React escapes the tags and displays the literal string.
However, there are XSS vectors that React does not protect against: dangerouslySetInnerHTML (bypasses escaping), href attributes with javascript: URLs, and server-side injection via __NEXT_DATA__. The __NEXT_DATA__ concern: Next.js serializes all props into a <script> tag. If any prop contains unsanitized user-generated content, it is serialized into the page source.
CSRF Protection in Next.js 16 — Built-In vs Manual
Next.js 16 provides built-in CSRF protection for Server Actions through the SameSite cookie attribute (default Lax) and the origin check. When a Server Action receives a request, it verifies the Origin or Referer header matches the deployment origin.
For API routes (Route Handlers), you must implement CSRF protection yourself. The standard approach: use a CSRF token pattern where the server generates a token, includes it in the response, and validates it on subsequent state-changing requests.
For forms using Server Actions, the built-in protection is usually sufficient. For API routes consumed by mobile apps or third-party services, implement token-based authentication instead.
Rate Limiting Server Actions and API Routes
Rate limiting is essential for any public-facing endpoint, including Server Actions. Without rate limiting, a malicious user can call a Server Action thousands of times per second, exhausting resources.
The challenge: each Server Action is a separate endpoint, so rate limiting must be applied at the action level. Use a distributed rate limiter (Redis-based) for multi-instance deployments.
Environment Variables and Secrets — The NEXT_PUBLIC Trap
Secret management has a critical pitfall: NEXT_PUBLIC_* environment variables are baked into the client-side bundle at build time. Any value with this prefix becomes visible in the browser’s JavaScript source.
The rule: never put a secret in a NEXT_PUBLIC_* variable. Use server-only environment variables (without the prefix) and access them via process.env in server components and route handlers.
Security Headers Beyond CSP — The Complete Suite
Content-Security-Policy is the most important security header, but it should not be the only one. A complete suite includes: Strict-Transport-Security (HTTPS enforcement), X-Frame-Options (clickjacking prevention), X-Content-Type-Options (MIME sniffing prevention), Referrer-Policy (referrer control), and Permissions-Policy (feature restriction).
These headers together provide defense-in-depth against multiple attack vectors. A single missing header can be exploited even with CSP in place.
REPL Summary — The 3 Commands That Verify Your Security Posture
Before every deployment, run these three commands:
curl -sI https://example.com | grep -iE '(content-security|strict-transport|x-frame|x-content|referrer|permissions)'— verifies all 6 security headers are present.curl -s https://example.com | grep -oE '"apiKey"|"secret"|"token"' | head -5— checks for exposed secrets in HTML source.curl -v -X POST -H 'Origin: https://evil.com' https://example.com/_next/... 2>&1 | grep -i 403— verifies Server Actions reject cross-origin requests.
XSS via Third-Party Widget — A Missing CSP Directive
default-src 'self' set. We didn’t realize that default-src is a fallback — it ONLY applies to directives that are not explicitly set. Since we omitted script-src, the browser used default-src for scripts, which allowed 'self' but also allowed inline scripts by default.default-src 'self' but no explicit script-src directive. Without script-src, the browser falls back to default-src for scripts. However, inline scripts and event handlers are controlled by script-src, and without an explicit directive, some browsers allow inline execution as the fallback. The chatbot widget dynamically injected a script tag that the browser executed because no CSP directive blocked it.script-src directive with strict hash-based allowlisting: script-src 'self' https://chatbot.example.com 'sha256-...'. Generated hashes for all inline scripts using the CSP evaluator tool. Added 'strict-dynamic' for compatibility with modern script loading patterns.- default-src is NOT a catch-all for security — you must explicitly set script-src, style-src, img-src, and connect-src for true protection.
- Third-party widgets that dynamically load scripts are a primary XSS vector — restrict them with specific domain allowlisting, never a wildcard.
- Generate script hashes or nonces for all inline scripts — the hash changes when the script content changes, preventing tampered execution.
- Test CSP with report-only mode (Content-Security-Policy-Report-Only) before enforcing it, to catch blocked legitimate scripts before users are affected.
curl -sI https://example.com | grep -i content-security-policycurl -sI https://example.com | grep -i 'script-src'| File | Command / Code | Purpose |
|---|---|---|
| next.config.js (partial) | script-src 'self' 'unsafe-eval' 'strict-dynamic' | CSP |
| scripts | for html_file in $(find .next/server -name '*.html'); do | Hash-Based Script Allowlisting |
| lib | export async function getUserData(userId: string) { | The Taint API |
| app | 'use server'; | Server Actions Security Model |
| components | export function SanitizedHTML({ html }: { html: string }) { | XSS Prevention in JSX |
| lib | export async function generateCsrfToken(): Promise | CSRF Protection in Next.js 16 |
| lib | export const rateLimit = new Ratelimit({ | Rate Limiting Server Actions and API Routes |
| lib | export const serverConfig = { | Environment Variables and Secrets |
| next.config.js | const securityHeaders = [ | Security Headers Beyond CSP |
| check-security.sh | curl -sI "$URL" | grep -iE '(content-security|strict-transport|x-frame|x-content... | REPL Summary |
Key takeaways
Interview Questions on This Topic
Design a layered security architecture for a Next.js 16 application handling user PII. Cover CSP, the Taint API, Server Actions, and secret management.
headers() function and verified in CI. Layer 2: Taint API enabled with experimental.taint in next.config.js. Every database query result containing PII is passed through taintObjectReference immediately after fetching. Server components return only sanitized subsets of data to client components. A CI check scans for NEXT_PUBLIC variables with secret-related names. Layer 3: Server Actions include explicit authorization checks using the session from auth(), input validation with Zod schemas, and rate limiting via Redis at 5 requests/minute for unauthenticated and 30/min for authenticated actions. Layer 4: All secrets are server-only environment variables accessed via process.env with validation at startup. A build-time script extracts inline script hashes from the production build and injects them into the CSP header. Verification curl commands run before every deployment.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
That's Next.js. Mark it forged?
4 min read · try the examples if you haven't