Blog · MCP Server Security

MCP server Content Security Policy nonce security — nonce reuse, JSONP bypass, strict-dynamic propagation

Nonce-based Content Security Policy is the strongest script injection defense for MCP server UIs, but five failure modes regularly defeat it: static nonces baked at build time, nonce reuse from CDN or template caching, streaming HTML timing mismatches, strict-dynamic trust propagating to attacker-controlled dynamic scripts, and JSONP endpoints on whitelisted origins serving attacker-controlled JavaScript. Each failure delivers complete CSP bypass.

CSP nonce: how it works and what it protects

The server generates a 256-bit cryptographically random value per HTTP request and places it in both the Content-Security-Policy header (script-src 'nonce-VALUE') and every authorized <script nonce="VALUE"> tag. The browser executes only scripts whose nonce attribute exactly matches the header value. An attacker who injects <script>evil()</script> has no nonce attribute and the browser blocks it — even if the injection reaches the DOM.

// Correct per-request nonce (Node.js):
import crypto from 'crypto';
const nonce = crypto.randomBytes(32).toString('base64url'); // 256 bits, new each request
res.setHeader('Content-Security-Policy', `script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'`);
res.setHeader('Cache-Control', 'no-store'); // REQUIRED — see caching failure mode below

Failure mode 1: static nonces

Static nonces — set once in an environment variable, build constant, or startup value — make the nonce permanently reusable. An attacker who observes any response has a forever-valid bypass: inject <script nonce="STATIC_VALUE">evil()</script> and the browser executes it because the nonce matches the header.

// WRONG: process.env.CSP_NONCE is the same every request
res.setHeader('Content-Security-Policy', `script-src 'nonce-${process.env.CSP_NONCE}'`);

// CORRECT: fresh random bytes per request — no shared state
const nonce = crypto.randomBytes(32).toString('base64url');

Failure mode 2: nonce reuse from CDN or template caching

A CDN or reverse proxy that caches the HTML response also caches the nonce. Subsequent users receive the same nonce value. An attacker who reads the cached nonce can inject <script nonce="CACHED"> into a different page load where the same cached nonce still appears in the CSP header.

// Required on all nonce-bearing responses:
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
// Configure CDN: bypass cache for all application HTML (static assets separately)

// Template caching variant: nonce injected at compile time, not render time
// WRONG:
const compiled = template.replace('NONCE', process.env.NONCE); // baked once
// CORRECT:
const html = template({ nonce: req.locals.cspNonce }); // injected at render

Failure mode 3: streaming HTML timing mismatch

In streaming responses (res.write() / chunked transfer), the CSP header is sent before the first body byte. If nonce generation happens inside an async streaming callback that fires after the first flush, the header nonce is absent but the script tag has a nonce — mismatch, all scripts blocked. Worse: if the nonce is generated before the header but the script tag nonce is set in a later chunk, an attacker observing the stream sees the nonce early and can craft an injection before the full page loads.

// CORRECT: generate nonce before any res.write()
app.get('/stream', (req, res) => {
  const nonce = crypto.randomBytes(32).toString('base64url');
  res.setHeader('Content-Security-Policy', `script-src 'nonce-${nonce}'`); // header first
  res.setHeader('Cache-Control', 'no-store');
  res.write(`<html><head><script nonce="${nonce}">init();</script></head>`);
  // ... stream body
});

Failure mode 4: strict-dynamic trust propagation

'strict-dynamic' propagates trust from nonced scripts to any scripts those scripts dynamically create (document.createElement('script'), import()). This allows module loaders to work without per-chunk nonces. The danger: if a nonced script calls eval() on tool result data, or loads a script from a URL derived from tool output, strict-dynamic treats those as trusted — the attacker gets code execution within the trusted script context.

// DANGEROUS: nonced script evaluates attacker-controlled data
<script nonce="abc">
  // strict-dynamic trusts this script — and its runtime behavior
  eval(toolResult.expression); // attacker-controlled — RCE via strict-dynamic
  const s = document.createElement('script');
  s.src = toolResult.pluginUrl;  // attacker-controlled URL executes without nonce check
  document.head.appendChild(s);
</script>

// SAFE: nonced script loads only from same-origin build-time paths
<script nonce="abc">
  ['chunk-a.js', 'chunk-b.js'].forEach(f => {
    const s = document.createElement('script');
    s.src = `/assets/${f}`; // path is build-time constant, not tool output
    document.head.appendChild(s);
  });
</script>

Failure mode 5: JSONP endpoint on whitelisted origin

If the script-src allowlist includes any origin that serves a JSONP endpoint (?callback= parameter that returns executable JavaScript), an attacker who injects <script src="https://[allowlisted]/?callback=evil"> gets CSP bypass. The browser allows the script because the origin is whitelisted.

// VULNERABLE CSP: whitelists a JSONP-capable origin
// script-src 'self' https://cdn.example.com

// cdn.example.com serves: https://cdn.example.com/data?callback=myFunc
// → returns: myFunc({...})  — executable JavaScript

// Attacker injects: <script src="https://cdn.example.com/data?callback=stealCookies"></script>
// CSP allows it (origin whitelisted). Browser executes. Cookies stolen.

// FIX: remove origin allowlist — use nonce-only + strict-dynamic:
// script-src 'nonce-{per-request}' 'strict-dynamic'
// No origin in allowlist = no JSONP bypass vector

CSP violation reports as a detection signal

MechanismHeaderBatchedUse
report-uri (deprecated)report-uri /csp-endpoint in CSPNo — per violationCompatibility; sends application/csp-report JSON
report-to (Reporting API)Reporting-Endpoints: csp="..." + report-to csp in CSPYes — up to 1 min delayPrimary; structured JSON; multi-type endpoint

Configure both. A spike in script-src-elem violations with blocked-uri: inline is a real-time signal of injection attempts — correlate with audit session IDs for attribution.

SkillAudit findings for CSP nonce configuration

CRITICAL Static nonce value in environment variable or build constant — permanently reusable by any attacker who observes one response; complete CSP bypass. Score: −24.
CRITICAL JSONP endpoint on script-src whitelisted origin — attacker-controlled callback parameter on a whitelisted origin delivers arbitrary JavaScript that CSP allows; CSP bypass via script-src allowlist. Score: −22.
HIGH Nonce-bearing HTML cached by CDN (Cache-Control missing) — fixed nonce reused across requests; attacker reads cached nonce to bypass future injection blocks. Score: −20.
HIGH Nonced script calls eval() or loads script from tool result URL — strict-dynamic propagates trust to attacker-controlled code; injection achieves execution within trusted script context. Score: −18.
MEDIUM No CSP violation reporting (no report-to or report-uri) — injection attempts produce no observable signal; attacker can probe injection vectors silently. Score: −12.

Audit your MCP server CSP nonce configuration

SkillAudit checks for static nonces, CDN caching of nonce-bearing responses, JSONP bypass candidates, strict-dynamic propagation risks, and missing violation reporting. Free audit in 60 seconds.

Free audit →