Published: May 8, 2026 · 17 min read
Content Security Policy in Next.js Micro Frontend
You ship a hardened Content-Security-Policy header to staging on Friday afternoon. The host renders. Every federated remote shows a blank white box. The browser console is full of red: Refused to load the script 'https://dev.myapp.com/products/_next/static/chunks/remoteEntry.js' because it violates the following Content Security Policy directive: "script-src 'self'" — followed by a second error, Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script. You add the remote URL to script-src. Now the host crashes the moment a Redux thunk fires — Refused to connect to 'https://api.dev.myapp.com'. You add the API to connect-src. The page renders, but Razorpay checkout opens a blank iframe and the Pay button does nothing. This is the content security policy next.js micro frontend problem: every directive (script-src, connect-src, frame-src, img-src, style-src, font-src) gates a different category of network call, and Module Federation, payment gateways, and analytics each need entries in two or three of them. In the previous article on next.js image optimization, you saw how every Next.js application in the federation has its own images.remotePatterns allowlist. CSP is the same coordination problem, except a missing entry is a security control, not just a 404. This article walks the complete headers() block, breaks down every directive, separates local vs production, covers the four security headers that complete the lockdown, and shows how to roll a CSP out in Report-Only mode without breaking production.
In this guide, you will:
- See the exact CSP errors that block Module Federation remotes, API calls, and payment iframes
- Build a complete
headers()function withContent-Security-Policyplus six security headers - Compare local development vs production CSP configs (the
http://localhost:*andws://localhost:*entries that must NEVER ship to production) - Understand why
unsafe-evalis non-negotiable for Module Federation in mid-2026 - Map every analytics, payment, and CDN dependency to the right directive —
script-src,connect-src,frame-src - Add the six security headers beyond CSP:
X-Content-Type-Options,X-Frame-Options,X-XSS-Protection,Referrer-Policy,Permissions-Policy,Strict-Transport-Security - Roll the policy out in Report-Only mode with a
/api/csp-reportendpoint before promoting to a blocking header - See the strict-nonce CSP pattern and why it does not yet work with Module Federation
- Avoid the ten CSP gotchas that surface only after a real-world deploy

Why CSP Is Harder in a Module Federation MFE
A Content Security Policy is a response header that tells the browser exactly which origins are allowed for each category of subresource — scripts, styles, images, fonts, fetch destinations, frames, fonts, and a few rarer categories. In a single-app Next.js project, every category typically has two or three entries: 'self', your CDN, your analytics provider. In a Module Federation MFE, every category gains entries because the federation runtime, every remote's bundle, every remote's API, every payment gateway, and every analytics provider all have their own URLs. Miss one and the feature half-breaks in a way that looks like a CORS error, a 404, or a silent UI freeze.
The root cause of most of these is a single line — webpack's federation runtime calls eval() to bootstrap a remote's shared scope. Without 'unsafe-eval' in script-src, the runtime crashes before a single remote module mounts. Until NextFederationPlugin (opens in a new tab) supports nonce-passthrough that lets remote chunks ride the host's per-request nonce, 'unsafe-eval' is non-negotiable. The right strategy is to keep that single relaxation in script-src and lock down every other directive as tightly as possible — pin specific origins, never wildcard, never list data: in script-src.
CSP is a defense-in-depth control, not a magic shield. A perfectly tuned CSP does not stop a server-side injection; it raises the cost of an XSS by orders of magnitude. The goal is to make every realistic XSS vector require a separate browser bug. See the MDN CSP guide (opens in a new tab) for the underlying threat model.
Step 1 — The Complete headers() Block (Production)
The CSP lives in Next.js's headers() async function. Every directive is a string, joined by ; into a single Content-Security-Policy header value. Six additional security headers go into the same block. The whole thing applies to every route via source: '/:path*'.
The block is long because real production traffic touches a lot of origins. Trim aggressively for your own stack — drop googletagmanager if you do not use Google Analytics, drop razorpay if you use Stripe, drop cloudflareinsights if you are not behind Cloudflare. The structure stays the same; only the per-directive entries change.
| Directive | Purpose | What goes wrong if missing |
|---|---|---|
default-src | Fallback for any directive not explicitly listed | A new directive (e.g. worker-src) silently inherits a stricter policy than you intended |
script-src | Sources for <script>, inline eval, federation runtime | White screen on every remote, GA fails to load |
style-src | Sources for <style> tags and style="..." attributes | Tailwind injected styles disappear, layout breaks |
img-src | Sources for <img>, <picture>, CSS background-image | Every CDN image breaks even if next/image remotePatterns allows it |
font-src | Sources for @font-face URLs | Custom fonts fall back to system serif |
connect-src | Sources for fetch, XHR, WebSocket, sendBeacon | Every API call and HMR connection fails |
frame-src | Sources for <iframe> and <frame> | Razorpay/Stripe checkout shows blank box |
object-src | Sources for <object>, <embed>, <applet> (legacy) | Set to 'none' — modern apps never need this |
base-uri | What can appear in <base href="..."> | Set to 'self' — prevents an injected <base> redirecting all relative URLs |
frame-ancestors | Origins allowed to embed THIS page in an iframe | CSP-3 replacement for X-Frame-Options |
form-action | Allowed <form action="..."> targets | Prevents form-based exfiltration |
upgrade-insecure-requests | Auto-rewrite http:// URLs to https:// | Stops mixed-content warnings on a fully-HTTPS site |
Step 2 — Local vs Production: Four Differences That Matter
Local development needs entries production must NOT have, and the production config has one header (HSTS) that would brick a developer's machine for two years if it leaked into local dev. Mirror the same shape in both environments — only the per-directive entries diverge.
The four lines that differ between local and production:
| Aspect | Local Development | Production |
|---|---|---|
http://localhost:* in script-src, style-src, img-src, frame-src | Yes — every React remote runs from http://localhost:4001..4011 | No — strip every localhost entry before deploy |
ws://localhost:* and wss://localhost:* in connect-src | Yes — required by webpack hot-reload | No — production has no HMR |
https://localhost:* in script/style/connect/frame | Yes — some remotes run with next dev --experimental-https | No |
Strict-Transport-Security header | Absent — HSTS on localhost cert breaks every other dev server | Present — max-age=63072000; includeSubDomains; preload |
The discipline is the same as for shared dependencies in Module Federation — every workspace declares the same shape; only environment-specific values change. A NODE_ENV check at the top of next.config.js is the simplest enforcement: a single const isDev = process.env.NODE_ENV !== 'production' flag that gates every dev-only entry.
Never ship http://localhost:* to production CSP. Leaving the entry in production means a request like https://www.myapp.com/_next/image?url=http://localhost/admin becomes a valid CSP-allowed fetch — and on a server where Next.js itself can reach loopback, that is an open proxy into anything bound to 127.0.0.1. Same risk as the http://localhost entry in images.remotePatterns covered in the previous article.
Step 3 — script-src: Why 'unsafe-eval' Stays
Every script that ever loads on the page is gated by script-src. In an MFE, that includes the Next.js page chunks, the federation runtime, every remote's remoteEntry.js, every analytics SDK, and every payment gateway loader. The directive is the most-touched one in any CSP, and the one most teams trim too aggressively.
The single most important entry is 'unsafe-eval'. Webpack's Module Federation runtime — the code that NextFederationPlugin generates — calls eval() to materialize a remote container's shared scope before any module from the remote can mount. Strip 'unsafe-eval' from script-src and every remote silently dies with Refused to evaluate a string as JavaScript. The host's static shell still renders, but every federated route is a blank box. This is the single biggest reason a CSP that worked perfectly in a single-app Next.js project does not survive the first day of an MFE rollout.
The second most-overlooked detail: googletagmanager.com and google-analytics.com are different subdomains, both required. GTM injects the bootstrap script (script-src entry: googletagmanager.com); GA collects the events GTM forwards (script-src + connect-src entry: google-analytics.com). Listing only one breaks the analytics chain in a way that is hard to spot — pageviews still record from cached sessions, but new sessions go dark.
Step 4 — connect-src: API + Federation + WebSocket
connect-src gates every URL that JavaScript actively connects to via fetch(), XMLHttpRequest, WebSocket, EventSource, navigator.sendBeacon(), and the WebRTC/WebTransport APIs. In an MFE, that includes every Redux thunk, every analytics beacon, every webpack HMR socket, and every Module Federation manifest fetch.
A common confusion is mistaking a connect-src violation for a CORS error. The browser console wording is different — a CORS error contains the word "CORS"; a CSP violation contains "Content Security Policy" or "Refused to connect". CORS is enforced by the response server's headers; CSP is enforced by the browser based on the page's response headers. They are unrelated controls that can both fail on the same request, but the fix is different: CORS = update the API server's Access-Control-Allow-Origin; CSP = update the host's connect-src directive.

Step 5 — img-src, style-src, font-src: The Static-Asset Trio
Three directives govern the assets that decorate a page: images, styles, and fonts. Each one is mostly straightforward, but each one has a subtle gotcha — img-src mostly sees 'self' because of next/image, style-src requires 'unsafe-inline' for almost every CSS-in-JS library, and font-src needs both the CSS subdomain (fonts.googleapis.com) AND the binary subdomain (fonts.gstatic.com).
img-src mostly looks like 'self' because next/image rewrites every CDN URL to /_next/image?url=... — the actual browser request lands on the host's optimizer endpoint. The CDN entries in img-src are still required for two cases: images that bypass next/image (<img src="https://cdn.myapp.com/...">) and background-image: url(...) rules in CSS. Mirror the entries to the same CDNs already in images.remotePatterns.
Step 6 — frame-src: Payment Iframes and Embedded Widgets
frame-src controls every origin allowed inside an <iframe> on your page. The most common producer of frame-src entries is a payment gateway — Razorpay, Stripe, PayPal each open a checkout iframe from a different origin. A frame-src miss looks like a payment failure to the user but is silent in the JavaScript console: the iframe simply renders blank.
The companion directive is frame-ancestors, which controls who is allowed to embed YOUR page inside an iframe. frame-ancestors 'self' is the modern replacement for X-Frame-Options: SAMEORIGIN — both should be sent (browsers prefer frame-ancestors when both are present, and older browsers fall back to X-Frame-Options).
Step 7 — Six Security Headers Beyond CSP
CSP is the largest header in the security block, but six more headers close attack vectors that CSP does not cover. Add them to the same headers array.
| Header | Closes |
|---|---|
X-Content-Type-Options: nosniff | MIME-sniffing attacks (uploaded JPEG executed as HTML) |
X-Frame-Options: SAMEORIGIN | Legacy clickjacking — modern browsers prefer frame-ancestors |
X-XSS-Protection: 1; mode=block | Legacy reflected-XSS filter (Safari/older clients) |
Referrer-Policy: strict-origin-when-cross-origin | URL/query-string leakage to third-party CDNs |
Permissions-Policy: camera=(), microphone=(), geolocation=(self) | Cross-origin iframe access to sensitive APIs |
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload | Forces HTTPS for two years and opts in to Chrome's preload list |
The one rule with HSTS: never send it in local development. A single https://localhost cert that ships HSTS will block every other dev server on localhost for the same max-age duration. Recovery requires chrome://net-internals/#hsts and "Delete domain security policies". The local next.config.js block in the Tabs above explicitly omits Strict-Transport-Security for this reason.
Step 8 — Roll It Out in Report-Only Mode First
Shipping a blocking CSP straight to production is the single biggest cause of "we deployed Friday and rolled back Monday" stories. The right pattern is to send the same policy as Content-Security-Policy-Report-Only, leave it on for at least seven days of real user traffic, and only promote to a blocking header after the violation rate drops to zero across a full traffic cycle.
Two flagged inputs you will see in the report stream that are NOT real bugs: browser extensions (Honey, Grammarly, AdBlock Plus) inject scripts under chrome-extension:// and moz-extension:// schemes, and crawlers send synthetic User-Agent strings that fail 'self' checks. Filter both before paging anyone — 90% of CSP violations in production come from extensions on user machines, not from your own code.
The rollout pipeline:
- Deploy
Content-Security-Policy-Report-Onlywith the strict policy. Keep an existing permissive policy (or no policy) blocking. - Watch
/api/csp-reportfor a full week including a weekend. - Treat new directive misses as code: add the legitimate origin to the policy, ship a new build.
- Once the violation rate is zero for 48 hours, rename the header from
Content-Security-Policy-Report-OnlytoContent-Security-Policy. - Production now blocks. The rollout is done.
The Strict-Nonce CSP — Why It Does Not Yet Work With Module Federation
The strongest possible CSP replaces 'unsafe-inline' with a per-request nonce: every <script> tag the server emits carries a nonce="..." attribute, the same nonce appears in script-src 'nonce-...', and any inline script an attacker injects via XSS does not have the matching nonce, so the browser refuses to execute it.
The pattern works perfectly in a single-app Next.js project. In a Module Federation MFE it does not — NextFederationPlugin does not pass the host's nonce through to the chunks emitted by remote builds, so a strict-nonce CSP rejects every remote chunk the moment it loads. Track the upstream issue on the @module-federation/universe (opens in a new tab) repo. Until nonce-passthrough lands, the strongest realistic CSP for an MFE is the directive-by-directive allowlist this article walks through — 'unsafe-inline' and 'unsafe-eval' stay in script-src, every other directive is pinned to specific origins.

Common Gotchas When Hardening a Next.js MFE With CSP
After running this CSP in production for months, ten gotchas account for almost every CSP-related incident. Every one of them is either a missing directive entry, a directive sent in the wrong environment, or a third-party (analytics, browser extension, payment gateway) doing something the policy did not anticipate.
If you remember one debugging step: read the CSP error word-for-word in the browser console and match the directive name to the right headers() entry. The error always names the violated directive (script-src, connect-src, frame-src, ...) and the blocked URI. The fix is always either adding the URI to that directive or removing the call site if the URI was unintended.
What's Next
You now have a complete CSP block for a Next.js Module Federation MFE — script-src with 'unsafe-eval' for the federation runtime, connect-src mirroring the API gateway and analytics endpoints, frame-src allowing payment-gateway iframes, img-src/style-src/font-src mirroring the CDN allowlist already in images.remotePatterns, six security headers beyond CSP, and a Report-Only rollout path that proves the policy on real user traffic before promoting to blocking. The next article kicks off Section 4 — Shared State Management with the pillar piece on sharing a Redux store across micro frontends — the singleton pattern, the shared package layout, the host-side Provider setup, and how a React MFE and a Next.js MFE both read from and write to the same store at runtime. The Next.js-specific Redux store article covered the Next.js half — the next article is the cross-framework version that works across React and Next.js remotes alike.
← Back to Image Optimization in Next.js Micro Frontend
Continue to Sharing Redux Store Across Micro Frontends →
Frequently Asked Questions
Why does Module Federation need 'unsafe-eval' in CSP script-src?
Webpack's Module Federation runtime calls eval() to bootstrap the remote container. When the host fetches a remote's remoteEntry.js, the federation runtime parses the manifest and uses eval() to materialize the shared scope before any remote module can mount. If 'unsafe-eval' is missing from script-src, the browser refuses to evaluate the runtime's eval() calls and every remote silently fails with a CSP violation that reads "Refused to evaluate a string as JavaScript". The whole page renders blank or shows only the host's static shell. Until NextFederationPlugin ships nonce-passthrough that lets a strict CSP cover federated chunks, 'unsafe-eval' is non-negotiable for any production MFE that uses Module Federation. The right mitigation is to keep every other CSP directive as tight as possible — pin script-src to specific origins, lock down connect-src to your API and analytics, and never wildcard a directive.
Where do I configure CSP headers in a Next.js micro frontend — host, every remote, or both?
Configure CSP in the host's next.config.js headers() function. The host is the only Next.js application whose origin the browser actually sees in the address bar — when a user opens https://www.myapp.com/products, the response from the host carries the CSP header that governs every script, image, and fetch on that page, including those emitted by the federated Products remote. Each remote should still set its own CSP for the case where a developer runs the remote standalone (cd apps/Products && next dev -p 3001), but in production, the host's policy is the one the browser enforces for every request that originates from the user-visible page. Mirror the same allowlist across host and remotes so a developer running a single remote in isolation does not see CSP errors that production never shows.
What's the difference between connect-src and frame-src in CSP?
connect-src governs URLs that JavaScript actively connects to via fetch(), XMLHttpRequest, WebSocket, EventSource, navigator.sendBeacon(), and the WebRTC/WebTransport APIs. Every Redux thunk that calls fetch('/api/cart'), every analytics beacon, every webpack hot-reload WebSocket — all gated by connect-src. frame-src governs which origins are allowed inside <iframe> and <frame> elements on your page. Razorpay's checkout opens as an iframe from checkout.razorpay.com — that is frame-src. Razorpay's order-creation REST call lands at api.razorpay.com — that is connect-src. The same vendor often shows up in both directives because one feature uses both an iframe (UI) and a REST endpoint (API). Missing either one makes the feature half-broken in a way that is hard to diagnose without reading the console error word-for-word.
How do I roll out a CSP without breaking production?
Use Content-Security-Policy-Report-Only as the rollout header for at least seven days. The browser still validates every directive, but instead of blocking violations, it POSTs a JSON report to the report-uri endpoint. Implement /api/csp-report as a Next.js API route that logs the body and returns 204. After a week of real user traffic, audit the violations: real ones become new directive entries, browser-extension noise (chrome-extension://, moz-extension://) gets filtered out. Once the violation rate is zero across a full traffic cycle (weekday + weekend), promote the same policy from Content-Security-Policy-Report-Only to Content-Security-Policy. Production never breaks because the blocking policy was already proven on real traffic in report-only mode. The only teams that get burned by CSP rollouts are the ones that skip Report-Only and ship the blocking header straight to prod on a Friday.
What security headers should I set besides Content-Security-Policy?
Set six additional headers alongside CSP, all from the same headers() block. X-Content-Type-Options: nosniff stops the browser from MIME-sniffing a wrong Content-Type and treating an HTML upload as an executable. X-Frame-Options: SAMEORIGIN is the legacy clickjacking defense — modern browsers honor frame-ancestors in CSP first, but older clients still react to X-Frame-Options. X-XSS-Protection: 1; mode=block is the legacy reflected-XSS filter, harmless to keep on. Referrer-Policy: strict-origin-when-cross-origin keeps full URLs on same-origin requests but only sends the origin (no path) cross-origin, which is the right default for an MFE that talks to multiple analytics endpoints. Permissions-Policy: camera=(), microphone=(), geolocation=(self) disables APIs no remote in this federation needs, denying them to embedded iframes too. Strict-Transport-Security: max-age=63072000; includeSubDomains; preload forces HTTPS for the next two years and opts in to the Chrome HSTS preload list — but never set it in local development because HSTS on localhost will block every other dev server on that hostname for the same duration.
Can I use a strict CSP with nonces in a Module Federation app?
Not yet, in mid-2026. A strict-nonce CSP generates a per-request nonce in Next.js middleware, attaches it to every script and style tag the server emits, and rejects every inline script that does not carry the nonce. The pattern works perfectly in a single-app Next.js project. In a Module Federation MFE, NextFederationPlugin does not pass the host's nonce through to the remote's emitted scripts, so a strict-nonce CSP rejects every chunk a remote produces. The recommended pattern today is: keep 'unsafe-inline' and 'unsafe-eval' in script-src for the federation runtime, lock down every other directive as tightly as possible (specific origins, no wildcards, no data: in script-src), and track the upstream issue. When nonce-passthrough lands, swap 'unsafe-inline' for 'nonce-...' in middleware. Until then, the strongest realistic CSP for an MFE is the directive-by-directive allowlist this article walks through.
Why does my CSP work in Chrome but break in Safari?
Safari implements CSP3 with stricter handling of 'strict-dynamic' and is the most likely browser to surface a runtime-hash mismatch from a webpack-emitted bootstrap chunk. Chrome and Firefox tend to be more permissive when a directive is ambiguous; Safari interprets the spec literally. The defense is the same as for any other browser-specific CSP issue: roll the policy out in Report-Only mode first, monitor /api/csp-report for at least a full week of real traffic, and check the user-agent breakdown of the violations. Safari-only violations almost always point to either a 'strict-dynamic' usage that the federation runtime cannot satisfy, or a script chunk whose hash drifted between build and deploy. Reproduce in Safari Technology Preview before promoting the policy to blocking mode.