← Blog

Claude skill security headers audit

Beyond Content-Security-Policy, four HTTP response headers collectively protect Claude MCP skill web pages from clickjacking via cross-origin iframes, MIME-type confusion attacks, internal URL disclosure through the Referer header, and unrestricted access to sensitive browser APIs. Skills that expose HTTP endpoints — OAuth callback pages, status dashboards, result rendering pages, error pages — must set X-Frame-Options, X-Content-Type-Options: nosniff, Referrer-Policy, and Permissions-Policy alongside a strict CSP. Omitting any one of these leaves a distinct attack surface open. SA-SKL-SEC-001 through SEC-004 describe each missing header pattern, the specific attack it enables, and the single-line fix that closes it.

By SkillAudit  |  3 Nov 2026

Developers building MCP skills often focus security hardening on their data layer — parameterised queries, Zod input validation, SSRF-safe URL handling. HTTP response headers that govern browser behaviour on skill-served pages are frequently overlooked. These headers are enforced exclusively by the browser; the skill has no other mechanism to instruct the browser to block cross-origin framing, reject MIME sniffing, withhold the Referer header on outbound navigation, or restrict feature access. Each missing header costs one line to add. Each missing header represents a distinct class of attack that that line would prevent.

SA-SKL-SEC-001: missing X-Frame-Options header enables clickjacking via cross-origin iframe embedding

A skill that returns HTML without an X-Frame-Options header (or an equivalent Content-Security-Policy: frame-ancestors directive) can be embedded inside an iframe on an attacker-controlled page. The attacker overlays a transparent layer over the skill’s UI, positioning interactive elements — buttons, form fields, confirmation dialogs — beneath deceptive visual labels. A user believing they are clicking “Continue to safe page” on the attacker’s site is actually clicking “Authorise payment” or “Delete account” on the invisible skill frame. For MCP skills that implement OAuth flows, settings pages, or confirmation dialogs, clickjacking can result in account takeover or authorisation of unintended operations. The X-Frame-Options: DENY header instructs all browsers to refuse to render the response inside any frame. X-Frame-Options: SAMEORIGIN permits embedding only from the same origin. The modern equivalent is Content-Security-Policy: frame-ancestors 'none', which provides more granular control and is respected by all current browsers; X-Frame-Options provides backwards compatibility with older browsers that do not support the CSP directive.

// VULNERABLE: Express OAuth callback page with no X-Frame-Options
import express from 'express';
const app = express();

app.get('/oauth/callback', (req, res) => {
  const { code, state } = req.query;
  // ... exchange code for token ...

  // No X-Frame-Options header — attacker embeds this page in a transparent iframe
  // User thinks they're clicking a button on attacker's page but clicks "Authorise"
  res.send(`
    <!doctype html><html><head><title>Authorise</title></head>
    <body>
      <form method="POST" action="/oauth/grant">
        <button type="submit">Grant access to your account</button>
      </form>
    </body></html>
  `);
  // Attacker iframe: opacity:0.001, positioned over "Download free PDF" button
  // User click triggers form submission → attacker gains OAuth grant
});

// ALSO VULNERABLE: status pages, settings pages, any UI with actions
app.get('/skill/settings', (req, res) => {
  res.send(`<!doctype html><html><body>
    <button id="delete-account">Delete account</button>
  </body></html>`);
  // No X-Frame-Options — transparent iframe + overlay = blind deletion
});

// SECURE: add X-Frame-Options (and frame-ancestors CSP) to every HTML response
import helmet from 'helmet';

// Option 1: use helmet — sets X-Frame-Options: SAMEORIGIN and CSP frame-ancestors 'self'
app.use(helmet());

// Option 2: explicit headers for stricter DENY policy
app.use((req, res, next) => {
  res.setHeader('X-Frame-Options', 'DENY');
  // CSP frame-ancestors is respected over X-Frame-Options in modern browsers
  // set it in your Content-Security-Policy header too:
  // Content-Security-Policy: ...; frame-ancestors 'none'
  next();
});

// DETECTION (SkillAudit):
// res.send() | res.render() | res.sendFile() with HTML content-type
// AND no res.setHeader('X-Frame-Options', ...) in route or middleware
// AND no frame-ancestors directive in Content-Security-Policy
const missingXFO = searchAST(
  'HTMLResponse AND NOT X-Frame-Options AND NOT frame-ancestors'
);
if (missingXFO) → HIGH  // SA-SKL-SEC-001

Any HTML page with a clickable action is a clickjacking target: OAuth consent pages, account settings, payment confirmation, and delete/revoke dialogs are the highest-risk targets. An attacker does not need to inject any code into your skill — they create their own page that frames yours. The entire attack happens in the attacker’s domain. X-Frame-Options: DENY costs one header line and eliminates the attack surface entirely. For skills using Content-Security-Policy, frame-ancestors 'none' in the CSP directive achieves the same result and also covers older CSP-aware browsers that ignore X-Frame-Options.

HIGH
SA-SKL-SEC-001 — Missing X-Frame-Options: HTML responses served without X-Frame-Options header or frame-ancestors CSP directive; skill page can be embedded in a cross-origin iframe; attacker overlays deceptive UI to trigger unintended user actions (clickjacking). Detection: HTML response with no X-Frame-Options and no frame-ancestors in CSP → HIGH.

SA-SKL-SEC-002: missing X-Content-Type-Options: nosniff enables MIME sniffing of skill responses

When a skill returns a response with Content-Type: text/plain but does not set X-Content-Type-Options: nosniff, Internet Explorer and some legacy browser codepaths attempt to determine the “true” content type by inspecting the response body. If the content looks sufficiently like HTML or JavaScript — starts with <html>, <script>, or has identifiable JS syntax — the browser renders it as HTML or executes it as JavaScript regardless of the declared Content-Type. This matters for MCP skills that return user-controlled data. A skill that echoes a tool argument as text/plain, believing it is safely non-executable, may be MIME-sniffed as a script by an older browser or a browser in a legacy compatibility mode. The attacker constructs a tool argument whose content passes the MIME sniffer’s heuristic, then tricks a user into navigating directly to the skill’s response URL in their browser. Without nosniff, the browser executes the “text/plain” response as a script. With X-Content-Type-Options: nosniff, the browser treats the response strictly as the declared content type and refuses to render it as executable content.

// VULNERABLE: skill returns user-controlled data as text/plain without nosniff
import express from 'express';
const app = express();

app.get('/skill/result', (req, res) => {
  const output = req.query.data as string;

  // Content-Type: text/plain declared — developer assumes this is safe
  // But without X-Content-Type-Options: nosniff, IE and legacy modes MIME-sniff
  // If output contains "<script>alert(1)</script>" or valid JS, some browsers execute it
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
  res.send(output);
  // Attack: GET /skill/result?data=<html><body><script>stealCookies()</script>
  // → MIME sniffer detects HTML → browser renders and executes as HTML
});

// ALSO VULNERABLE: JSON responses without nosniff
// Some browsers sniff JSON containing HTML-like values as text/html
app.get('/skill/data', (req, res) => {
  res.setHeader('Content-Type', 'application/json');
  // Missing X-Content-Type-Options: nosniff — older browsers may MIME-sniff
  res.json({ result: req.query.q });
});

// SECURE: set X-Content-Type-Options: nosniff on all responses
import helmet from 'helmet';

// Option 1: helmet sets nosniff automatically
app.use(helmet());

// Option 2: explicit middleware — applies to all responses
app.use((req, res, next) => {
  res.setHeader('X-Content-Type-Options', 'nosniff');
  next();
});

// Also ensure Content-Type is always explicitly set — never rely on Express defaults
app.get('/skill/result', (req, res) => {
  const output = escapeHtml(req.query.data as string);
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.send(output);
  // nosniff: browser honours declared Content-Type regardless of body content
  // output is also HTML-escaped: belt and suspenders
});

// DETECTION (SkillAudit):
// Any res.send() | res.json() | res.sendFile() without nosniff header in middleware chain
const missingNosniff = searchAST(
  'HTTPResponse AND NOT X-Content-Type-Options: nosniff AND NOT helmet()'
);
if (missingNosniff) → MEDIUM  // SA-SKL-SEC-002

nosniff is one header with no downside: it tells the browser “trust the Content-Type I declared; do not guess.” Modern browsers already default to not sniffing in most contexts, but the header enforces this behaviour explicitly and provides defence-in-depth for edge cases. It should be on every response, set once in middleware. The one scenario where it matters most — a skill returning user-controlled text — is exactly the scenario where many skill developers have omitted it.

MEDIUM
SA-SKL-SEC-002 — Missing X-Content-Type-Options: nosniff: responses served without X-Content-Type-Options: nosniff; browser MIME-sniffing interprets text/plain response body containing HTML/JS syntax as executable content in legacy browser codepaths. Detection: any HTTP response without X-Content-Type-Options: nosniff in header or middleware → MEDIUM.

SA-SKL-SEC-003: missing Referrer-Policy leaks internal API endpoint structure via Referer header

When a user navigates from a skill-served page to an external resource — clicking a documentation link, loading a third-party font or analytics script, following an OAuth redirect — the browser includes a Referer header on that outbound request. By default, the Referer header contains the full URL of the origin page, including path and query string. For MCP skill pages, the URL path often encodes sensitive information: session tokens in query parameters, internal endpoint names that reveal backend architecture, user IDs embedded in result page paths, or OAuth state values. A third-party service that receives the Referer header gains visibility into your skill’s internal URL structure and any sensitive values embedded in it. This information appears in third-party access logs and analytics dashboards, persists beyond the session, and cannot be retroactively scrubbed. The Referrer-Policy response header controls what portion of the URL is included in the Referer header. strict-origin-when-cross-origin sends only the origin (scheme + host, no path or query) when navigating to a different origin, and the full URL when navigating within the same origin. no-referrer omits the header entirely on all requests. Neither has any observable effect on the user experience; both prevent internal URL structure from leaking to third parties.

// VULNERABLE: skill page includes third-party resources without Referrer-Policy
// URL encodes session token and internal route: /skill/result?session=abc123&from=internal-admin-api
import express from 'express';
const app = express();

app.get('/skill/result', (req, res) => {
  const { session, from } = req.query;
  // No Referrer-Policy — browser sends full URL in Referer header to any third party
  // When user clicks the analytics link, third-party receives:
  // Referer: https://skill.example.com/skill/result?session=abc123&from=internal-admin-api
  res.send(`
    <!doctype html><html><head>
      <!-- Third-party analytics: receives Referer with session token and internal path -->
      <script src="https://analytics.third-party.com/track.js"></script>
    </head>
    <body>
      <p>Result ready. <a href="https://docs.external.com/guide">View documentation</a></p>
      <!-- External link click: Referer exposes full URL including session token -->
    </body></html>
  `);
});

// SECURE: set Referrer-Policy to limit what is sent in Referer header
app.use((req, res, next) => {
  // strict-origin-when-cross-origin: send only origin (no path/query) for cross-origin requests
  // This is the W3C default for browsers that don't honour the header — but setting it
  // explicitly ensures consistent behaviour across all browsers and contexts
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
  next();
});

// For high-sensitivity skill pages (OAuth, admin, settings): use no-referrer
app.get('/oauth/callback', (req, res) => {
  res.setHeader('Referrer-Policy', 'no-referrer');
  // Third-party scripts on this page receive no Referer information at all
  // Prevents OAuth state value in URL from leaking to analytics scripts
  res.send('...');
});

// ALSO: move sensitive values out of URLs where possible
// Bad:  /skill/result?session=abc123
// Good: store session in HttpOnly cookie; URL contains only public resource ID

// DETECTION (SkillAudit):
// HTML response pages that load third-party scripts or include external links
// AND no Referrer-Policy header set on the response
const missingRefererPolicy = searchAST(
  'HTMLResponse AND (externalScript OR externalLink) AND NOT Referrer-Policy'
);
if (missingRefererPolicy) → LOW  // SA-SKL-SEC-003

Session tokens in query parameters are the worst case: any URL containing a session token, OAuth code, or state value in the query string will expose that value in the Referer header to every third-party resource on the page. Beyond Referrer-Policy, the root fix is keeping sensitive values in cookies or POST bodies, not in URLs. Referrer-Policy: strict-origin-when-cross-origin is the baseline that should be set on every response; for OAuth callbacks and session-sensitive pages, no-referrer provides the strictest protection.

LOW
SA-SKL-SEC-003 — Missing Referrer-Policy: skill HTML responses served without Referrer-Policy header; browser sends full URL — including path and query parameters — in Referer header on outbound requests to third-party scripts and links; internal endpoint structure and URL-embedded tokens leak to third-party access logs. Detection: HTML response with external resources and no Referrer-Policy header → LOW.

SA-SKL-SEC-004: missing Permissions-Policy header leaves browser feature access unrestricted

The Permissions-Policy header (formerly Feature-Policy) allows a skill’s HTML page to explicitly declare which browser APIs are permitted within its context and within any embedded iframes. Without it, any third-party script loaded on the skill page — analytics, embedded widgets, CDN-hosted libraries — can silently request access to the camera, microphone, geolocation, payment APIs, USB, Bluetooth, and other sensitive device capabilities, subject only to the user’s browser-level grant. For a skill page that has no legitimate need for these APIs, the appropriate posture is to lock them all down: Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(). This does not affect skill functionality (since the skill page does not use these APIs), but it prevents any third-party script on the page from requesting them — and prevents any embedded iframe from requesting them on behalf of the page’s origin. Skills that do legitimately use one of these APIs should lock down all APIs they do not use while explicitly allowing only the ones they need: Permissions-Policy: microphone=(self), camera=(), geolocation=(). The header enforces the principle of least privilege at the browser API layer.

// VULNERABLE: skill page loads third-party scripts without Permissions-Policy
import express from 'express';
const app = express();

app.get('/skill/dashboard', (req, res) => {
  // No Permissions-Policy — third-party scripts can request camera/microphone/geolocation
  // Even if the skill never uses these APIs, a compromised CDN or analytics script
  // can trigger browser permission prompts, surprising users and undermining trust
  res.send(`
    <!doctype html><html><head>
      <script src="https://cdn.analytics.com/tracker.js"></script>
      <!-- If tracker.js is compromised, it can ask for camera/mic access -->
    </head>
    <body>...</body></html>
  `);
});

// ALSO VULNERABLE: skill that embeds an iframe without Permissions-Policy
app.get('/skill/embed', (req, res) => {
  res.send(`
    <!doctype html><html><body>
      <!-- Embedded third-party iframe inherits all permissions from parent -->
      <iframe src="https://widget.third-party.com/embed"></iframe>
    </body></html>
  `);
  // Without Permissions-Policy: the embedded iframe can access any API
  // the user has previously granted to any origin on this page
});

// SECURE: Permissions-Policy locks down all APIs the skill does not use
app.use((req, res, next) => {
  // Lock down all sensitive browser APIs by default
  // An empty () means "deny for all origins including self"
  res.setHeader('Permissions-Policy', [
    'camera=()',
    'microphone=()',
    'geolocation=()',
    'payment=()',
    'usb=()',
    'bluetooth=()',
    'accelerometer=()',
    'gyroscope=()',
    'magnetometer=()',
    'clipboard-read=()',
    'clipboard-write=(self)',  // allow self to use clipboard write (copy button)
    'fullscreen=(self)',
  ].join(', '));
  next();
});

// For a skill that legitimately uses microphone (voice input skill):
app.get('/skill/voice', (req, res) => {
  // Allow microphone for self only; deny everything else
  res.setHeader('Permissions-Policy',
    'microphone=(self), camera=(), geolocation=(), payment=()'
  );
  res.send('...');
});

// DETECTION (SkillAudit):
// HTML responses without Permissions-Policy header in middleware chain
const missingPermissions = searchAST(
  'HTMLResponse AND NOT Permissions-Policy AND NOT helmet.permittedCrossDomainPolicies()'
);
if (missingPermissions) → MEDIUM  // SA-SKL-SEC-004

Permissions-Policy + CSP + X-Frame-Options together form the complete browser security header stack: CSP controls script execution and resource loading; X-Frame-Options controls framing; Permissions-Policy controls hardware API access. All three should be set in a single middleware that applies to every HTML response. Skills that use helmet() get most of this automatically — but helmet’s default Permissions-Policy is minimal; review and extend it to lock down all APIs your skill does not require.

MEDIUM
SA-SKL-SEC-004 — Missing Permissions-Policy: HTML responses served without Permissions-Policy header; third-party scripts and embedded iframes inherit unrestricted access to browser APIs (camera, microphone, geolocation, payment); compromised supply-chain script triggers unexpected permission requests in skill context. Detection: HTML response without Permissions-Policy header → MEDIUM.

Remediation summary

FindingSeverityFix
SEC-001: Missing X-Frame-OptionsHIGHAdd X-Frame-Options: DENY to all HTML responses; also add frame-ancestors 'none' to Content-Security-Policy for modern browser enforcement; use helmet() to apply globally
SEC-002: Missing X-Content-Type-Options: nosniffMEDIUMSet X-Content-Type-Options: nosniff on all responses in a middleware; helmet() sets this automatically; always declare explicit Content-Type headers alongside it
SEC-003: Missing Referrer-PolicyLOWSet Referrer-Policy: strict-origin-when-cross-origin globally; use no-referrer on OAuth and session-sensitive pages; move sensitive values from URL query params to cookies or POST bodies
SEC-004: Missing Permissions-PolicyMEDIUMSet Permissions-Policy with () for all APIs the skill does not use; only allow (self) for APIs the skill explicitly requires; review and extend helmet() defaults

SkillAudit detects all four SA-SKL-SEC-001 through SEC-004 missing security header patterns through static analysis of Express middleware chains and individual route handler header assignments. For the complementary Content-Security-Policy findings, see SA-SKL-CSP-001 through CSP-004 (CSP bypass: missing header, unsafe-inline, predictable nonce, connect-src wildcard) and SA-SKL-CSP-005 through CSP-008 (cross-origin isolation failures). See the complete SA-SKL attack surface reference for the full vulnerability taxonomy.

Scan your Claude skill for missing security headers

SkillAudit detects missing X-Frame-Options, nosniff, Referrer-Policy, and Permissions-Policy headers across the full SA-SKL-SEC family in 60 seconds.

Get your free audit