Content Security Policy for Module Federation #

Module Federation injects script elements at runtime, which is exactly what a Content Security Policy is designed to stop — this page shows how to allow it without falling back to unsafe-inline or unsafe-eval.

The failure is abrupt: the policy ships, the host renders, and every remote silently refuses to load with a console message about a violated directive. The fix is not to loosen the policy but to tell the federation runtime about it. This is the first control described in securing federated remotes.

Why the policy comes after the reports A policy derived from real traffic includes the origins that only appear on rarely-visited routes; one derived from the architecture diagram does not. Policy written from the diagram script-src lists the two known CDNs connect-src forgotten entirely Entry loads, chunks are refused Remote half-initialises and dies Policy written from report data Report-only run for a full traffic cycle Every origin discovered, including rare routes script-src, connect-src, img-src all listed Enforcement is a no-op on day one
The report-only phase is not caution — it is the only reliable inventory of what your application loads.

Prerequisites #

Step 1 — Enumerate what the page actually loads #

Do not write the policy from the architecture diagram. Deploy a report-only policy first and let real traffic tell you what loads.

add_header Content-Security-Policy-Report-Only "
  default-src 'self';
  script-src 'self';
  connect-src 'self';
  report-uri /csp-report;
" always;

Leave this running for at least a full week. Federated routes load conditionally, so a remote behind a feature flag or an infrequent route will not appear in the first hours of reports.

// server/csp-report.js — collect and group, do not alert on every report
app.post('/csp-report', express.json({ type: ['json', 'application/csp-report'] }), (req, res) => {
  const r = req.body['csp-report'] ?? req.body;
  logger.info('csp', {
    directive: r['violated-directive'],
    blocked: r['blocked-uri'],
    page: r['document-uri'],
  });
  res.status(204).end();
});

Group by blocked-uri and you get an inventory of every origin your application depends on — which is usually longer than anyone expected, and is valuable well beyond this task.

Step 2 — Write a source-list policy for the remote origins #

For federation specifically, a source-list policy is simpler and nearly as strong as a nonce-based one. List the origins and nothing else.

add_header Content-Security-Policy "
  default-src 'self';
  script-src 'self' https://cdn.example.com https://checkout-cdn.example.com;
  connect-src 'self' https://cdn.example.com https://checkout-cdn.example.com https://api.example.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https://cdn.example.com;
  font-src 'self' https://cdn.example.com;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';
" always;

Three directives are routinely missed and all three break federated pages:

connect-src must include every remote origin. The federation runtime fetches chunks with fetch, not only through script tags, and a policy that allows the script but not the fetch produces a remote that starts loading and then fails halfway.

img-src and font-src must include the remote origins if remotes ship their own assets, which they normally do — see handling CSS and asset loading in Vite remotes.

base-uri 'self' is not about remotes at all, but it matters more here than elsewhere: an injected <base> tag rewrites where every relative asset resolves from, including a remote’s chunks, which turns a minor injection into full script control.

Step 3 — Propagate a nonce, if you need a strict policy #

Propagating a nonce to the federation runtime The federation runtime creates its own script elements, so a nonce policy only works when the runtime is told the nonce before any remote loads. Making a strict policy work with dynamic injection Server issues nonce fresh per response Entry sets it __webpack_nonce__ first statement Runtime injects scripts carry the nonce strict-dynamic chunks inherit trust
Set the nonce before the bootstrap import — after it, the earliest injected scripts have already been refused.

If your organisation requires a nonce-based policy, the federation runtime needs the nonce, because it creates the script elements itself.

<!-- Server-rendered: a fresh, unguessable nonce per response -->
<script nonce="{{cspNonce}}">
  window.__CSP_NONCE__ = "{{cspNonce}}";
</script>
// src/index.js — the FIRST statement, before any remote is touched
__webpack_nonce__ = window.__CSP_NONCE__;

// Only now is it safe to bootstrap.
import('./bootstrap');

Webpack reads __webpack_nonce__ when it creates script and style elements, so setting it before anything else runs is what makes dynamically-injected remotes pass the policy.

The nonce must be regenerated per response and must never be cached. A nonce baked into a cached HTML page is a constant, which makes the policy ornamental.

# Nonce-based policy — 'strict-dynamic' lets trusted scripts load further scripts
add_header Content-Security-Policy "
  script-src 'nonce-$request_id' 'strict-dynamic' https:;
  object-src 'none';
  base-uri 'self';
" always;

'strict-dynamic' is what makes this workable with federation. Without it, every chunk a remote loads would need its own nonce, which is not achievable.

Step 4 — Handle the Vite development server #

Vite’s development server injects inline scripts for hot module replacement, and its client uses eval in some configurations. A production policy applied in development will block your own dev server.

// vite.config.ts — different policy per mode, explicitly
export default defineConfig(({ mode }) => ({
  plugins: [
    federation({ /* … */ }),
    {
      name: 'dev-csp',
      configureServer(server) {
        server.middlewares.use((_req, res, next) => {
          if (mode !== 'development') return next();
          // Development only. Production headers come from the edge, not from here.
          res.setHeader('Content-Security-Policy',
            "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:*;");
          next();
        });
      },
    },
  ],
}));

Make the difference explicit and loud, as the comment above does. The dangerous version of this is a single policy that silently includes 'unsafe-eval' because it was needed in development three years ago.

Step 5 — Verify the policy in CI, not in production #

A policy is configuration, and configuration drifts. Assert on it.

// test/csp.spec.mjs
import { test, expect } from '@playwright/test';

test('production CSP allows every remote origin and forbids unsafe directives', async ({ request }) => {
  const res = await request.get(process.env.PREVIEW_URL);
  const csp = res.headers()['content-security-policy'];

  expect(csp, 'CSP header must be present').toBeTruthy();
  for (const origin of ['https://cdn.example.com', 'https://checkout-cdn.example.com']) {
    expect(csp).toContain(origin);
  }
  expect(csp).not.toContain("'unsafe-eval'");
  expect(csp).toContain("object-src 'none'");
  expect(csp).toContain("base-uri 'self'");
});

Pair it with a rendering test that loads a federated route and fails on any console message mentioning a CSP violation. That catches the case where the header is correct but a remote loads from an origin nobody registered.

Step 6 — Keep the origin list and the manifest in agreement #

The directives a federated page actually needs Federation touches more directives than script-src alone, and each omission produces a different partial failure. Directive Must include Symptom if missing script-src every remote origin entry refused, nothing renders connect-src every remote origin entry loads, chunks fail img-src / font-src origins of remote assets unstyled or image-less remote base-uri 'self' injected base rewrites every asset URL object-src 'none' legacy plugin embedding stays available
The second row is the one that costs the most debugging time: the remote appears to load and then simply stops.

The policy lists allowed origins. The manifest decides which URLs the host requests. When they disagree, the browser blocks the request and you get a confusing runtime error rather than a clear one.

Validate the manifest against the same list, in code, before fetching anything:

// src/verify-origins.js
const ALLOWED = new Set(['https://cdn.example.com', 'https://checkout-cdn.example.com']);

export function assertAllowedOrigins(manifest) {
  const bad = Object.entries(manifest)
    .filter(([, spec]) => !ALLOWED.has(new URL(spec.entry).origin))
    .map(([name, spec]) => `${name}${new URL(spec.entry).origin}`);

  if (bad.length) throw new Error(`manifest points at unlisted origins: ${bad.join(', ')}`);
}

Deriving both the header and this constant from one configuration file removes the drift entirely, and is worth the small amount of build plumbing. The broader validation is covered in validating remote manifests before loading.

Step 7 — Decide what happens when the policy blocks a remote #

A blocked remote is a runtime failure like any other, and by default it is a bad one: the browser refuses the script, the federation runtime’s promise rejects with an opaque message, and whatever component was awaiting it either throws or hangs. None of that names the policy as the cause, which is why CSP problems are so often diagnosed as “federation being flaky”.

Make the failure legible. The script injection helper is the right place, because it is the only code that knows a script element was created and never loaded.

// src/inject-script.js — distinguish a blocked script from a missing one
export function injectScript(url) {
  return new Promise((resolve, reject) => {
    const el = document.createElement('script');
    el.src = url;
    el.crossOrigin = 'anonymous';
    el.onload = () => resolve();
    el.onerror = () => {
      // A CSP refusal and a 404 both surface as a bare error event, so the
      // origin check is what tells them apart for the person reading the log.
      const origin = new URL(url).origin;
      const listed = document.querySelector('meta[name="allowed-remote-origins"]')
        ?.content.split(' ').includes(origin);
      reject(new Error(listed
        ? `remote entry failed to load: ${url}`
        : `remote origin ${origin} is not in the allowed origin list — check the CSP`));
    };
    document.head.appendChild(el);
  });
}

Beyond the message, decide the behaviour deliberately. A blocked remote should fail closed — render the page without it and report the failure — rather than triggering a retry loop against an origin the policy will refuse every time. Retries against a blocked origin generate console noise and telemetry volume without any chance of succeeding, and they mask the real problem behind a wall of identical errors.

It is also worth reporting these separately from ordinary remote-load failures. A spike in genuine load errors usually means a CDN problem; a spike in policy refusals means someone changed a manifest, added an origin, or deployed a remote somewhere new. Those are different incidents with different owners, and the error boundary telemetry already wrapping each remote is the natural place to make the distinction.

Verification #

Troubleshooting #

Symptom: remotes load in staging, are blocked in production.

Diagnosis: production serves remotes from an additional origin — commonly a separate CDN for one team. Fix: enumerate origins from the manifest rather than from memory, and assert on the list in CI as in step 5.

Symptom: the remote entry loads but the remote never renders.

Diagnosis: script-src allows the entry while connect-src blocks the chunk fetches that follow. Fix: add every remote origin to connect-src as well.

Symptom: adding a nonce broke everything.

Diagnosis: __webpack_nonce__ is set after the first dynamic import, so the earliest injected scripts have no nonce. Fix: set it as the very first statement of the entry file, before the import('./bootstrap') boundary.

Symptom: CSP violations reported from a page nobody can reproduce.

Diagnosis: a browser extension injecting scripts, which produces reports indistinguishable from real violations. Fix: filter reports whose blocked-uri is chrome-extension: or moz-extension: before alerting on them.