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
| Mechanism | Header | Batched | Use |
|---|---|---|---|
report-uri (deprecated) | report-uri /csp-endpoint in CSP | No — per violation | Compatibility; sends application/csp-report JSON |
report-to (Reporting API) | Reporting-Endpoints: csp="..." + report-to csp in CSP | Yes — up to 1 min delay | Primary; 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
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 →