CSP Security · June 2026 · 18 min read

MCP Server Content Security Policy Deep Dive: Nonce-Based Inline Scripts, strict-dynamic, and report-uri Monitoring for Browser-Based Agent UIs

Browser-based MCP server client UIs render tool output that may contain attacker-controlled content. A prompt-injection payload in a tool result that causes the UI to insert HTML sets up cross-site scripting — and XSS in an authenticated MCP UI is not a simple cookie-steal: it's session hijacking with full access to every tool your agent can call. A production-grade Content Security Policy closes the XSS surface, but most MCP UIs are shipped with either no CSP or one that is trivially bypassed by a single 'unsafe-inline' directive. This post is the complete playbook: baseline policy construction, per-request nonces, strict-dynamic trust propagation, sandboxed tool output rendering, frame-ancestors for clickjacking prevention, and report-uri violation telemetry.

Why MCP server browser UIs need a harder CSP than a typical web app

Most web application CSP guides start from the attacker model of a reflected or stored XSS that injects a <script> tag into the page. For MCP server browser UIs, the threat model is different and more severe:

  1. Prompt injection via tool output — An attacker embeds instructions in a web page that your fetchPage tool retrieves. The LLM follows those instructions and includes attacker-controlled text in its response. If the UI renders LLM output as HTML, the attacker controls HTML in your authenticated UI.
  2. Privileged context — The user is authenticated. The session has access to every tool the MCP server exposes. XSS in an MCP UI doesn't just steal a session cookie — it can call executeCode, readFile, sendEmail, or deleteRecord with the full permissions of the authenticated user, invisibly to them.
  3. Rendered rich output — MCP tool outputs are increasingly rendered as markdown, HTML tables, or embedded previews. Every rendering pathway that doesn't treat tool output as untrusted text is a potential XSS sink.

The stakes are higher than a typical app. XSS in an MCP UI doesn't just exfiltrate your session — it can leverage every tool the agent has. A compromised MCP UI with a runShellCommand tool is equivalent to remote code execution on the server. CSP is not optional for MCP UIs.

Phase 1: Build the baseline from default-src 'none'

Most CSP guides recommend starting from a permissive policy and tightening it. For a new MCP server UI, start from default-src 'none' — a complete deny-all — and add back only what you actually need. This inverts the risk: you add explicit grants rather than hoping you've blocked all injection vectors.

1

Baseline: default-src 'none' + minimum viable grants

Start with this policy and verify your UI works. If it doesn't, add grants one at a time.

Content-Security-Policy:
  default-src 'none';
  script-src 'self';
  style-src 'self';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' wss://api.skillaudit.dev;
  frame-src 'none';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

This is the policy before nonces. It allows:

Don't omit base-uri. base-uri is not covered by default-src. Without an explicit base-uri directive, a <base href="https://attacker.com/"> injection redirects all relative script src attributes — including your own <script src="/app.js"> — to load from the attacker's server.

Phase 2: Add nonces for necessary inline scripts

Most MCP UIs need at least one inline script block — for bootstrapping configuration, setting up a WebSocket URL, or initializing a UI framework with data that must be available before the first network request. 'unsafe-inline' in script-src defeats the entire CSP and should never be used. The correct mechanism is a per-request cryptographic nonce.

How nonces work

The server generates a random nonce for every HTTP response. It includes that nonce in both the CSP header and the nonce attribute on allowed inline <script> tags. The browser only executes inline scripts whose nonce attribute matches the value in the CSP header — and since the nonce is random per request, an attacker who injects a <script> tag doesn't know the current request's nonce and cannot forge a matching attribute.

2

Per-request nonce generation in Node.js (Express/Fastify)

Generate a fresh nonce on every request, not at startup. A static nonce is no better than 'unsafe-inline'.

import crypto from 'node:crypto';

// Express middleware
function cspMiddleware(req, res, next) {
  // 128 bits of entropy, base64url-encoded
  const nonce = crypto.randomBytes(16).toString('base64url');

  // Attach to res.locals so templates can access it
  res.locals.cspNonce = nonce;

  // Build the CSP header string
  const csp = [
    "default-src 'none'",
    `script-src 'nonce-${nonce}' 'strict-dynamic'`,
    "style-src 'self'",
    "img-src 'self' data: https:",
    "font-src 'self'",
    `connect-src 'self' wss://${process.env.API_HOST}`,
    "frame-src 'none'",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
    `report-uri /csp-violations`,
  ].join('; ');

  res.setHeader('Content-Security-Policy', csp);
  next();
}

app.use(cspMiddleware);

In your HTML template, attach the nonce to every inline script:

<!-- EJS template -->
<script nonce="<%= cspNonce %>">
  window.__MCP_CONFIG__ = {
    wsUrl: '<%= wsUrl %>',
    userId: '<%= userId %>',
  };
</script>

<!-- Inline event handlers are NOT covered by nonces — they're always blocked -->
<!-- Wrong: <button onclick="doThing()">Click</button> -->
<!-- Right: addEventListener in your nonce-gated script block -->

Nonces are invalidated by caching. Never set long Cache-Control headers on pages that include CSP nonces. If a CDN caches the response, all users receive the same nonce — and the attacker can read the nonce from the cached page and inject a matching nonce attribute. Set Cache-Control: no-store on any response that includes a CSP nonce.

Phase 3: strict-dynamic to handle dynamically loaded scripts

Modern MCP UIs use JavaScript frameworks that load additional scripts at runtime — React's lazy-loaded route chunks, Vite's dynamically split modules, or a third-party charting library loaded on demand. Without 'strict-dynamic', you'd need to add every CDN or bundle URL to your script-src allowlist — and an allowlist is exactly what attackers work to bypass via compromised CDN files or path traversal.

'strict-dynamic' changes the trust model: any script that was loaded via a nonce-bearing <script> tag is trusted to load further scripts. Trust propagates dynamically through the script loading chain, without requiring host-based allowlists.

Without strict-dynamic (fragile)

You must list every CDN domain that can serve scripts. Any CDN with user-controlled paths is a bypass. Doesn't cover dynamic imports.

With strict-dynamic (robust)

Only scripts loaded by a nonce-bearing root script can run. You don't enumerate CDN hosts. Dynamic import() and document.createElement('script') in your trusted code both work.

# With strict-dynamic, this CSP header covers your bundled app AND all its dynamic imports:
Content-Security-Policy:
  script-src 'nonce-{REQUEST_NONCE}' 'strict-dynamic';
  ...

# Your HTML only needs the nonce on the entry-point script tag:
<script nonce="{REQUEST_NONCE}" src="/app.js" type="module"></script>
# app.js can dynamically import() other chunks — all covered by strict-dynamic propagation.

strict-dynamic and backwards compatibility. 'strict-dynamic' was introduced in CSP Level 3 (Chrome 52+, Firefox 52+, Safari 15.4+). For older browsers that don't understand it, include 'unsafe-inline' after 'strict-dynamic' in the directive — modern browsers that support nonces will ignore the 'unsafe-inline' fallback, while old browsers will fall back to it. This is an intentional backwards-compatible design in the CSP spec.

Phase 4: Sandboxed iframes for tool output rendering

Some MCP tools return rich content: HTML reports, data visualizations, embedded previews from external tools. Rendering this output directly in the main document context — even with DOMPurify sanitization — gives that content access to the main window's JavaScript context, storage, and network credentials. The safer pattern is rendering tool output in a sandboxed iframe.

4

Sandboxed iframe for tool output

Create a blob URL for tool output HTML and load it in a maximally-sandboxed iframe. Even if the tool output contains a complete XSS payload, the sandbox prevents it from reaching the parent window.

function renderToolOutputSandboxed(htmlContent) {
  // Create a blob URL so the iframe has its own origin (null origin)
  const blob = new Blob([htmlContent], { type: 'text/html' });
  const blobUrl = URL.createObjectURL(blob);

  const iframe = document.createElement('iframe');

  // Maximum sandbox restrictions:
  // - No allow-scripts: no JavaScript execution in the tool output
  // - No allow-same-origin: the iframe gets null origin, preventing storage access
  // - allow-popups-to-escape-sandbox: if you need links to open, but omit if not
  iframe.setAttribute('sandbox', 'allow-popups allow-popups-to-escape-sandbox');

  // Additional security via the CSP attribute (CSP Level 3)
  iframe.setAttribute('csp', "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:");

  iframe.setAttribute('loading', 'lazy');
  iframe.src = blobUrl;

  // Clean up blob URL after load to free memory
  iframe.addEventListener('load', () => URL.revokeObjectURL(blobUrl));

  return iframe;
}

// Update your main page CSP to allow blob: in frame-src:
// frame-src blob: 'none';  (blob: for sandboxed tool output iframes only)

The sandbox attribute without allow-scripts is total JavaScript isolation. A sandboxed iframe with sandbox="" (no flags) blocks scripts, plugins, form submission, same-origin access, popups, and pointer lock. Add flags back only for legitimate functionality: allow-popups lets links open, allow-popups-to-escape-sandbox lets those opened windows have normal capabilities. Never add allow-same-origin to an iframe that renders user/tool-supplied content — it breaks the origin isolation the sandbox provides.

Phase 5: style-src hardening — eliminating CSS injection

CSS injection is a less obvious XSS vector but a real one. An attacker who can inject arbitrary CSS into a page can exfiltrate data using CSS selectors combined with background-image: url() (the CSS exfiltration attack), overlay phishing content over the UI using position: fixed, or redirect form inputs. The style-src directive controls which stylesheets and inline styles are permitted.

# Ideal: no inline styles at all — all styles in external files
Content-Security-Policy: style-src 'self';

# If your framework injects inline style attributes (e.g. CSS-in-JS):
# Use nonces on style blocks the same way you use them on scripts
Content-Security-Policy: style-src 'self' 'nonce-{REQUEST_NONCE}';

# If you absolutely cannot avoid unsafe-inline for styles (legacy app):
# At minimum, block script-src unsafe-inline — CSS injection is lower risk than script injection
# But note: CSS-based attribute exfiltration still works with unsafe-inline styles
Content-Security-Policy: style-src 'self' 'unsafe-inline';

CSS-in-JS libraries and nonces. Libraries like styled-components, Emotion, and Linaria inject <style> tags at runtime. Styled-components (v5.1+) and Emotion support nonce injection via a global __webpack_nonce__ variable or the nonce attribute in their theme provider. Set this to your CSP nonce before rendering to avoid having to use 'unsafe-inline'.

Phase 6: upgrade-insecure-requests and mixed content

If your MCP UI is served over HTTPS but any resource it loads — scripts, images, API calls — is fetched over HTTP, the browser will warn about mixed content and may block the request. The upgrade-insecure-requests CSP directive instructs the browser to automatically upgrade HTTP subresource requests to HTTPS before making them. It does not affect navigation (clicking links), only passive and active mixed content.

Content-Security-Policy:
  ...
  upgrade-insecure-requests;

This is a belt-and-suspenders directive. Your actual defense is ensuring all resources are served over HTTPS. upgrade-insecure-requests catches misconfigured hardcoded http:// URLs in your own templates that your developers missed.

Phase 7: report-uri and report-to — detecting bypasses in production

A CSP header with no reporting is silent on violations. You don't know if attackers are attempting injections, if your policy is blocking legitimate functionality you missed in testing, or if a third-party script your CDN serves has been compromised and is being blocked. The report-uri and report-to directives instruct the browser to POST a JSON violation report to an endpoint you control.

7

CSP violation reporting endpoint

Add a report-uri to your CSP and a lightweight endpoint to collect and log violations.

// Add to your CSP header:
// report-uri /csp-violations
// report-to csp-endpoint

// The newer Reporting API header (for report-to):
// Report-To: {"group":"csp-endpoint","max_age":86400,"endpoints":[{"url":"/csp-violations"}]}

// Express endpoint:
app.post('/csp-violations', express.json({ type: 'application/csp-report' }), (req, res) => {
  const report = req.body['csp-report'];
  if (!report) return res.status(400).end();

  // Log to your observability stack — violations are noisy, aggregate them
  logger.warn('CSP violation', {
    blockedUri: report['blocked-uri'],
    violatedDirective: report['violated-directive'],
    originalPolicy: report['original-policy'],
    documentUri: report['document-uri'],
    referrer: report['referrer'],
    // Never log the full source-file or script-sample in production without PII scrubbing
  });

  res.status(204).end();
});

// Rate-limit this endpoint — browsers retry violations,
// and an attacker can trigger thousands of reports as a DoS vector
app.use('/csp-violations', rateLimit({ windowMs: 60_000, max: 100 }));

Use report-only mode first. When adding CSP to an existing MCP UI, deploy it first as Content-Security-Policy-Report-Only instead of Content-Security-Policy. This reports violations without blocking anything — you can observe what your existing policy would break before enforcing it, avoiding accidental breakage for your users.

What CSP violation reports reveal

violated-directiveblocked-uri prefixInterpretation
script-srcinlineInjected inline script blocked — likely an XSS attempt or a third-party tool injecting scripts
script-srcevaleval() call detected — either your own code or injected code using eval for obfuscation
script-srcexternal URLThird-party script blocked — a CDN dependency not in your allowlist, or a compromised CDN file from a new URL
connect-srcexternal URLOutbound data exfiltration attempt (XSS payload sending data to attacker server)
frame-ancestorsYour MCP UI being embedded in an iframe on another origin — clickjacking attempt
img-srcexternal URLCSS-based attribute exfiltration attempt, or tool output loading external images

The complete production CSP for an MCP server browser UI

Putting all phases together, this is the production policy served on authenticated MCP UI pages:

Content-Security-Policy:
  default-src 'none';
  script-src 'nonce-{REQUEST_NONCE}' 'strict-dynamic' 'unsafe-inline';
  style-src 'self' 'nonce-{REQUEST_NONCE}';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' wss://api.yourdomain.com;
  frame-src blob:;
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';
  upgrade-insecure-requests;
  report-uri /csp-violations;

Notes on this policy:

Common MCP server CSP mistakes

Mistake: script-src 'unsafe-inline'

Allows any injected <script> tag to execute. Completely defeats the purpose of a CSP. Never acceptable in a production MCP UI.

Mistake: script-src 'unsafe-eval'

Allows eval(), Function(), and setTimeout(string). XSS payloads obfuscated as strings can execute via these sinks. Avoid entirely or confine to a sandboxed worker.

Mistake: script-src *

Wildcard allows any HTTPS script from any domain. An attacker who controls any HTTPS URL (CDN path traversal, compromised dependency, attacker.com) can inject scripts. Equivalent to no CSP for script injection.

Mistake: Omitting frame-ancestors

Without frame-ancestors, your MCP UI can be embedded in an iframe on any origin. The attacker overlays a transparent iframe over a legitimate-looking page and tricks the user into clicking MCP tool actions (clickjacking).

Mistake: Static nonce across requests

A nonce set at server startup and reused for every response is trivially readable by the attacker (they just view source once). Nonces MUST be regenerated with crypto.randomBytes() per response.

Mistake: Rendering tool output in main document

Even with DOMPurify, rendering rich tool output (HTML reports, markdown with embedded HTML) in the main document context gives that content access to the JS context. Use sandboxed iframes with sandbox="".

CSP deployment checklist for MCP server UIs

SkillAudit findings for CSP in MCP servers

CRITICAL −24No Content-Security-Policy header on authenticated MCP UI pages — XSS has unrestricted script execution in the agent session context
CRITICAL −22script-src 'unsafe-inline' present — inline script injection is fully permitted, negating any XSS protection the CSP might otherwise provide
HIGH −18Static nonce reused across responses — nonce is predictable and provides no injection protection; same security posture as 'unsafe-inline'
HIGH −16frame-ancestors directive missing — MCP UI can be embedded in cross-origin iframes; clickjacking of tool-execution buttons possible
HIGH −14Tool output rendered in main document context without sandbox — prompt-injection → XSS gives attacker full DOM and JS context access
MEDIUM −12script-src * wildcard — any HTTPS URL can serve scripts; CDN path traversal or compromised dependencies bypass the CSP
MEDIUM −10object-src not set — inherits from default-src only if default-src is restrictive; an explicit object-src 'none' is required to block plugin content unconditionally
MEDIUM −8No CSP violation reporting — active XSS attempts are silent; no signal to alert on or investigate

Summary

Content Security Policy for browser-based MCP server UIs needs to be more rigorous than for a standard web app, because XSS in an MCP UI gives the attacker the full capability surface of every tool the authenticated user can call. The effective defense is a layered policy built in phases: start from default-src 'none', add per-request nonces for any necessary inline scripts, use 'strict-dynamic' to propagate trust through dynamic imports without CDN host allowlists, render all tool output HTML in maximally sandboxed iframes, block framing with frame-ancestors 'none', and instrument everything with report-uri to surface active attacks in production.

The mistake that ends careers in MCP security is shipping an authenticated UI with 'unsafe-inline' in script-src because it was the path of least resistance during development. A nonce-based policy with strict-dynamic is not significantly harder to build — it just requires the nonce middleware and a template change. The security gain is closing the entire inline-script injection surface permanently, which is the single most impactful CSP change you can make.

SkillAudit checks for missing or misconfigured CSP headers, unsafe-inline directives, static nonces, and missing frame-ancestors as part of every audit. See a sample audit report or run a free audit on your MCP server.