MCP Server Security

Permissions Policy for HTTP-transport MCP servers

The Permissions-Policy HTTP header (formerly Feature-Policy) lets you disable browser APIs — camera, microphone, geolocation, USB, payment, and more — for your server's pages. For HTTP-transport MCP servers that serve any HTML, this header closes a class of XSS escalation paths where injected scripts attempt to access hardware APIs or sensitive device data.

Why browser API lockdown matters for MCP servers

An HTTP-transport MCP server that serves an OAuth callback or an admin configuration page is a browser target. If an XSS vulnerability exists — even a minor reflected one — and no Permissions-Policy header is set, an injected script can request microphone access, enumerate USB devices, or attempt payment processing. These are capabilities your MCP server's HTML pages almost certainly don't need.

Permissions-Policy is defence-in-depth: it doesn't prevent XSS, but it limits what an exploited XSS can do. Combined with a strict Content Security Policy, it significantly reduces the blast radius of any browser-side code execution.

The recommended header for MCP servers

For a typical MCP server that serves configuration or OAuth pages but doesn't need any hardware API access:

Permissions-Policy:
  camera=(),
  microphone=(),
  geolocation=(),
  payment=(),
  usb=(),
  bluetooth=(),
  accelerometer=(),
  gyroscope=(),
  magnetometer=(),
  ambient-light-sensor=(),
  display-capture=(),
  document-domain=()

Empty parentheses () mean "deny this feature entirely" — no origin, including the page's own origin, may use it. This is the strictest setting and the right default for MCP server pages that don't use these APIs.

Adding the header in Express

const PERMISSIONS_POLICY = [
  'camera=()',
  'microphone=()',
  'geolocation=()',
  'payment=()',
  'usb=()',
  'bluetooth=()',
  'accelerometer=()',
  'gyroscope=()',
  'magnetometer=()',
  'ambient-light-sensor=()',
  'display-capture=()',
  'document-domain=()'
].join(', ')

app.use((req, res, next) => {
  res.setHeader('Permissions-Policy', PERMISSIONS_POLICY)
  next()
})

Adding the header in Fastify

import fp from 'fastify-plugin'

const permissionsPolicy = fp(async (fastify) => {
  fastify.addHook('onSend', async (request, reply) => {
    reply.header('Permissions-Policy', PERMISSIONS_POLICY)
  })
})

await fastify.register(permissionsPolicy)

Scoped permissions for multi-page servers

If your server serves both a public audit results page (no APIs needed) and an admin page that legitimately needs clipboard access for a "copy to clipboard" feature, scope the policy to the route rather than applying it globally:

// stricter policy for public pages
const PUBLIC_POLICY = [
  'camera=()', 'microphone=()', 'geolocation=()',
  'payment=()', 'usb=()', 'clipboard-read=()'
].join(', ')

// slightly relaxed for admin (clipboard write for copy-button)
const ADMIN_POLICY = [
  'camera=()', 'microphone=()', 'geolocation=()',
  'payment=()', 'usb=()',
  "clipboard-write=(self)"
].join(', ')

app.get('/admin/*', (req, res, next) => {
  res.setHeader('Permissions-Policy', ADMIN_POLICY)
  next()
})

app.use((req, res, next) => {
  res.setHeader('Permissions-Policy', PUBLIC_POLICY)
  next()
})

The (self) value means only the page's own origin can access the feature — not third-party frames or scripts. (self "https://trusted.example.com") extends permission to a specific trusted origin.

The legacy Feature-Policy header

Older browsers (pre-2020) used Feature-Policy with a different syntax. For maximum compatibility, send both headers:

// modern Permissions-Policy (Chrome 88+, Firefox 74+, Safari 16+)
res.setHeader('Permissions-Policy', PERMISSIONS_POLICY)

// legacy Feature-Policy for older browsers
res.setHeader('Feature-Policy',
  "camera 'none'; microphone 'none'; geolocation 'none'; payment 'none'"
)

For MCP servers in 2026, the legacy header is rarely necessary — the agents and tooling that consume MCP servers run on modern runtimes. But if your admin UI has users on corporate browsers that may be pinned to older versions, sending both adds no meaningful overhead.

iframe allow attribute

If your MCP server renders any <iframe> elements, the Permissions-Policy header alone isn't sufficient — you must also set the allow attribute on each iframe to restrict what the embedded content can access:

<!-- iframe with no permissions granted to the embedded content -->
<iframe
  src="https://docs.example.com/embed"
  allow="camera 'none'; microphone 'none'; geolocation 'none'"
  sandbox="allow-scripts allow-same-origin"
></iframe>

The sandbox attribute is a separate but complementary control. Using both allow and sandbox provides layered restriction: sandbox disables capabilities by default, and allow (via Permissions Policy) prevents re-enabling them from within the iframe.

What SkillAudit flags

SkillAudit checks for the Permissions-Policy header on all HTML responses from HTTP-transport MCP servers. A missing header is a Medium finding. The audit also flags Feature-Policy headers that use the old 'none' syntax without a corresponding modern Permissions-Policy header — the old syntax is deprecated and browsers are removing support.

Audit your MCP server's response headers

SkillAudit checks Permissions-Policy, Content-Security-Policy, HSTS, and 12 other security headers for HTTP-transport MCP servers. Free for public repos.

Run a free audit