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:
- Prompt injection via tool output — An attacker embeds instructions in a web page that your
fetchPagetool 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. - 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, ordeleteRecordwith the full permissions of the authenticated user, invisibly to them. - 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.
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:
- script-src 'self' — only scripts loaded from your own origin; no inline scripts, no eval, no data URLs
- style-src 'self' — only stylesheets from your origin
- img-src 'self' data: https: — images from your origin, data URIs (for inline SVGs/favicons), and HTTPS sources (for tool output that includes images you can't pre-load)
- connect-src 'self' wss://api… — XHR/fetch to your API and WebSocket to your agent endpoint; adjust to your actual domains
- frame-src 'none' — no iframes at all yet; we'll add sandboxed iframes in Phase 3
- object-src 'none' — blocks Flash, Java applets, and all plugin content
- base-uri 'self' — prevents
<base>tag injection, which can redirect all relative URLs to an attacker-controlled origin - form-action 'self' — prevents form submissions being redirected to exfiltration endpoints
- frame-ancestors 'none' — prevents your MCP UI from being embedded in an attacker-controlled iframe (clickjacking defense; supersedes
X-Frame-Options: DENY)
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.
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.
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.
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-directive | blocked-uri prefix | Interpretation |
|---|---|---|
script-src | inline | Injected inline script blocked — likely an XSS attempt or a third-party tool injecting scripts |
script-src | eval | eval() call detected — either your own code or injected code using eval for obfuscation |
script-src | external URL | Third-party script blocked — a CDN dependency not in your allowlist, or a compromised CDN file from a new URL |
connect-src | external URL | Outbound data exfiltration attempt (XSS payload sending data to attacker server) |
frame-ancestors | — | Your MCP UI being embedded in an iframe on another origin — clickjacking attempt |
img-src | external URL | CSS-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:
'unsafe-inline'inscript-srcis ignored by browsers that support nonces (CSP Level 2+), so it only affects very old browsers as a fallbackframe-src blob:allows the sandboxed tool output iframes from Phase 4 (blob URLs);'none'would block themwss://api.yourdomain.comallows your WebSocket agent connection; adjust to your actual API domain{REQUEST_NONCE}is replaced by your middleware with a freshcrypto.randomBytes(16).toString('base64url')value on every response
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
- Deploy as
Content-Security-Policy-Report-Onlyfirst and observe violations for one week before switching to enforcement - Generate nonces with
crypto.randomBytes(16).toString('base64url')per HTTP response — never reuse nonces - Set
Cache-Control: no-storeon all responses that include nonces — cached nonces are reusable by attackers - Include
'strict-dynamic'inscript-srcto eliminate CDN host allowlists - Set
frame-ancestors 'none'to prevent clickjacking of MCP tool actions - Render tool output HTML in sandboxed iframes using blob URLs (no
allow-same-originin sandbox) - Set
object-src 'none'andbase-uri 'self'— these are not covered bydefault-src - Add
report-uri /csp-violationswith rate limiting on the collection endpoint - Wire CSP violation logs into your alerting — a spike in
script-src inlineviolations is an active attack signal - If using CSS-in-JS, configure the library to accept a nonce value via its server-side rendering API
- Test your policy with Google's CSP Evaluator before shipping
SkillAudit findings for CSP in MCP servers
script-src 'unsafe-inline' present — inline script injection is fully permitted, negating any XSS protection the CSP might otherwise provide'unsafe-inline'frame-ancestors directive missing — MCP UI can be embedded in cross-origin iframes; clickjacking of tool-execution buttons possiblescript-src * wildcard — any HTTPS URL can serve scripts; CDN path traversal or compromised dependencies bypass the CSPobject-src not set — inherits from default-src only if default-src is restrictive; an explicit object-src 'none' is required to block plugin content unconditionallySummary
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.