Blog · 2026-06-21 · Permissions Policy · Feature Policy · iframe Security · MCP Servers

MCP Server Permissions Policy Security: Permissions-Policy header, feature gating, iframe allow= attribute, sandbox vs Permissions-Policy, and Feature-Policy v1 vs v2

The Permissions-Policy HTTP header is the document-level kill switch for browser APIs: camera, microphone, geolocation, payment, USB, serial, Bluetooth, and compute-pressure. For MCP servers that render tool output in iframes — or whose client-side JavaScript might process tool responses in the browser — an explicit deny-all Permissions-Policy means even a fully successful XSS injection cannot open the victim's camera, access their location, or initiate a Web Payment. This is a rare example of a security control that survives code-level exploitation: the browser enforces the policy header regardless of what JavaScript tries.

What Permissions-Policy actually controls

When a browser renders a document, it builds a "feature policy set" that determines which powerful browser APIs are available to that document and to every <iframe> embedded within it. The Permissions-Policy header, sent by the server alongside the HTML response, is the primary mechanism for configuring that set.

The header syntax is a structured field: a comma-separated list of feature names, each followed by a parenthesized allowlist. An empty allowlist — () — means "no origin may use this feature." A value of (self) means only the same-origin document may use it. A value of * means any origin may use it (including cross-origin iframes). Examples:

# Deny all device APIs — strongest hardening posture
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(),
  usb=(), hid=(), serial=(), bluetooth=(), screen-wake-lock=(),
  compute-pressure=(), ambient-light-sensor=(), accelerometer=(),
  gyroscope=(), magnetometer=()

# Allow geolocation for same-origin but deny camera/mic entirely
Permissions-Policy: geolocation=(self), camera=(), microphone=()

# Allow payment for same-origin and a known payment iframe origin
Permissions-Policy: payment=(self "https://pay.stripe.com")

Features not mentioned in the header default to the browser's built-in default, which varies per feature. Most powerful APIs default to (self) — allowed for the top-level document, denied for cross-origin iframes. Some features like ambient-light-sensor default to () already. The practical implication is that omitting Permissions-Policy is not the same as denying everything: the defaults leave several APIs available to the top-level document.

MCP attack scenario: An MCP tool processes a web page and returns HTML that, when rendered in the client's browser, executes navigator.mediaDevices.getUserMedia({audio: true}) to start recording audio. Without Permissions-Policy, the browser prompts the user for microphone access. With Permissions-Policy: microphone=() set on the MCP client's origin, the getUserMedia call throws a NotAllowedError immediately, no prompt shown, no access possible — regardless of what JavaScript runs.

The fourteen APIs you should deny by default

SkillAudit audits for these features on every MCP server and client deployment:

Feature name What it gates Default without header Recommended
cameraVideo capture via getUserMedia()(self)()
microphoneAudio capture via getUserMedia()(self)()
geolocationnavigator.geolocation.getCurrentPosition()(self)()
paymentPayment Request API, Web Payments(self)() unless payment flow needed
usbWebUSB — enumerate and communicate with USB devices(self)()
hidWebHID — Human Interface Devices (gamepads, etc.)(self)()
serialWeb Serial API — communicate with serial ports(self)()
bluetoothWeb Bluetooth — connect to Bluetooth devices(self)()
screen-wake-lockPrevent screen from sleeping(self)()
compute-pressureRead CPU/GPU thermal and utilisation data()()
ambient-light-sensorRead ambient light levels()()
accelerometerDevice motion — acceleration (physical attack leakage)(self)()
gyroscopeDevice orientation — gyroscope(self)()
magnetometerCompass orientation(self)()

For an MCP server that serves no device-oriented features (the vast majority), a single header line covers all fourteen:

Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(),
  usb=(), hid=(), serial=(), bluetooth=(), screen-wake-lock=(),
  compute-pressure=(), ambient-light-sensor=(), accelerometer=(),
  gyroscope=(), magnetometer=()

sandbox vs Permissions-Policy — two orthogonal mechanisms

Developers frequently confuse the HTML sandbox attribute on <iframe> with Permissions-Policy. They are orthogonal controls that restrict different things:

sandbox=""

Controls iframe capabilities: can the iframe run scripts? Can it navigate the top-level window? Can it submit forms? Can it open popups? Can it access same-origin content? Without allow-scripts, JavaScript is disabled. Without allow-same-origin, the iframe is forced into a unique origin. Without allow-forms, form submission is blocked. Sandbox applies only to <iframe> elements; there is no equivalent HTTP header for the top-level document.

Permissions-Policy

Controls which browser APIs are accessible. JavaScript can run (sandbox allows it), but getUserMedia(), navigator.geolocation, and WebUSB are blocked by the browser before the API call reaches the OS. Applies to both the top-level document (via HTTP header) and to iframes (via HTTP header from the iframe's server and via the parent's allow= attribute). Does not affect script execution, form submission, or navigation.

A correct setup for an MCP client that renders tool output in a sandboxed iframe looks like this:

<!-- Sandbox: scripts allowed (to render interactive tool output),
     forms blocked, popups blocked, navigation blocked,
     but same-origin access allowed so postMessage can work -->
<iframe
  src="/tool-output-renderer"
  sandbox="allow-scripts allow-same-origin"
  allow="camera 'none'; microphone 'none'; geolocation 'none'; payment 'none'"
></iframe>

The allow= attribute uses a semicolon-separated list of feature-origin pairs and restricts the iframe further — but it can only restrict relative to what the parent document's Permissions-Policy allows. If the parent HTTP header already says camera=(), the iframe cannot use the camera regardless of whether the allow= attribute specifies it. The allow= attribute is an additional restriction layer, not an expansion mechanism.

Critical order dependency: The allow= attribute on an iframe cannot grant permissions that the parent document's Permissions-Policy denies. The parent header is the ceiling; the allow= attribute can only lower the floor. This means hardening the parent document with a strict Permissions-Policy transitively hardens all embedded iframes — even ones you add in the future — without updating their allow= attributes.

Cross-origin iframe tool output rendering

Some MCP client architectures render tool output in a cross-origin iframe — a sandboxed origin like sandbox.skillaudit.dev serves as the rendering context. This is a stronger security boundary than same-origin sandboxing because the iframe's browsing context is in a separate origin, meaning XSS inside the iframe cannot reach the parent document's cookies, DOM, or storage. However, cross-origin iframes have their own Permissions-Policy configuration from their own HTTP response headers.

This creates a two-layer policy system:

  1. Parent document header: Sets the feature availability ceiling for the iframe. If the parent says camera=(), the iframe cannot use the camera regardless of its own header.
  2. iframe's HTTP header: The cross-origin server serving the iframe's content sends its own Permissions-Policy header. This applies to the iframe's document.
  3. Parent's allow= attribute: Further restricts the intersection of the above two.

The effective policy for a feature in an iframe is the intersection of: (a) the parent's Permissions-Policy allows it for the iframe's origin, (b) the parent's allow= attribute allows it, and (c) the iframe's own server sends a Permissions-Policy that allows it. All three must permit the feature for it to be available inside the iframe.

1

MCP tool processes external URL — returns HTML with embedded JavaScript targeting navigator.mediaDevices.getUserMedia({video: true})

2

Client renders tool output — injects HTML into DOM or loads it in an iframe. Script executes.

3

getUserMedia() call hits browser API layer — browser checks the feature policy for the current browsing context before dispatching the OS permission prompt.

4

Without Permissions-Policy: browser prompts user for camera access. Social engineering can complete the grant. With camera=(): browser throws NotAllowedError immediately, no prompt, no access.

Feature-Policy v1 vs Permissions-Policy v2

The header was originally named Feature-Policy when it shipped in Chrome 60. The W3C Permissions Policy specification renamed it to Permissions-Policy in 2020, changed the syntax from HTTP header field list to structured field dictionary, and revised how default allowlists work. The two versions are not equivalent and browsers that support the new header ignore the old one.

Attribute Feature-Policy (v1) Permissions-Policy (v2)
Header nameFeature-PolicyPermissions-Policy
Value syntaxSpace-separated directives: camera 'none'Structured field: camera=()
Deny syntax'none' keywordEmpty allowlist ()
Self-only syntax'self' keyword(self)
Wildcard syntax**
iframe attributeallow= (already used v2 syntax)allow=
Browser supportChrome 60–73 only; deprecatedChrome 74+, Firefox 74+, Safari 16+
Feature namesSome differ (e.g. vr)Updated names (e.g. xr-spatial-tracking)

Migration risk: Servers that still send Feature-Policy headers are not protected in modern browsers — the header is silently ignored. SkillAudit flags legacy Feature-Policy headers as HIGH severity because they give a false sense of security: the team believes the policy is active when it is not enforced by current browser versions.

Interaction with the Permissions API

The browser's Permissions API (navigator.permissions.query()) allows JavaScript to check whether a feature is currently granted, denied, or prompt-pending — without triggering the actual permission prompt. A Permissions-Policy that denies a feature causes navigator.permissions.query({name: 'camera'}) to return state 'denied', just as if the user had previously denied the permission in their browser settings. This prevents JavaScript from distinguishing between "user denied" and "policy denied" — both return the same state, and neither allows access.

This interplay matters for MCP server audit work: an audit that checks navigator.permissions.query() will correctly report 'denied' for features blocked by Permissions-Policy, giving false confidence that the user explicitly denied access. SkillAudit's probe suite tests Permissions-Policy enforcement directly at the API call level, not via navigator.permissions, to avoid this ambiguity.

Caddy and nginx configuration

Setting the header in Caddy (the factory's default server):

# Caddyfile
skillaudit.dev {
  header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), hid=(), serial=(), bluetooth=(), screen-wake-lock=(), compute-pressure=(), ambient-light-sensor=(), accelerometer=(), gyroscope=(), magnetometer=()"
  file_server {
    root /srv/product
  }
}

In nginx:

add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), hid=(), serial=(), bluetooth=(), screen-wake-lock=(), compute-pressure=(), ambient-light-sensor=(), accelerometer=(), gyroscope=(), magnetometer=()" always;

The always flag in nginx is critical: without it, the header is only sent on 2xx responses. On 4xx and 5xx responses — which might also render HTML in an error page — the header is omitted, leaving error pages unprotected.

Verifying your Permissions-Policy header

Three verification approaches, in order of reliability:

  1. Browser DevTools: Open the Network tab, load the page, click the HTML document request, check the Response Headers section for Permissions-Policy. Then open the Console tab and try calling the gated API — a policy-denied API call should immediately throw NotAllowedError.
  2. curl: curl -sI https://skillaudit.dev/ | grep -i permissions-policy — this checks what the server sends without any browser processing.
  3. SecurityHeaders.com / Observatory: Third-party scanners check for the header and its completeness. They flag missing features and detect legacy Feature-Policy headers.

Permissions-Policy and Content Security Policy interaction

Permissions-Policy and CSP are complementary but non-overlapping controls:

Together, CSP restricts what code reaches the CPU and Permissions-Policy restricts what that code can do at the OS/hardware interface. For MCP servers rendering tool output, both are needed: CSP to limit where scripts can load from, Permissions-Policy to cap what those scripts can access even if they bypass CSP via inline injection.

SkillAudit findings for Permissions-Policy

HIGHNo Permissions-Policy header sent by the MCP server or client application. Browser defaults leave camera, microphone, geolocation, USB, HID, Serial, Bluetooth, screen-wake-lock, and sensor APIs accessible to the top-level document. Score −20.
HIGHLegacy Feature-Policy header found but no Permissions-Policy header. Feature-Policy is ignored by Chrome 74+, Firefox 74+, and Safari 16+. The policy appears configured but is not enforced. Score −18.
MEDIUMPermissions-Policy header present but missing high-risk features: camera, microphone, geolocation, or payment not explicitly denied. Partial policy leaves device API access possible. Score −14.
MEDIUMiframe rendering tool output lacks allow= attribute with explicit denials. Parent document's Permissions-Policy header covers the iframe only if it denies the feature — missing allow= with explicit deny prevents future additions from inheriting protection. Score −10.
MEDIUMnginx config uses add_header Permissions-Policy ... without always — header absent on 4xx/5xx error responses. Error pages rendering tool output diagnostics are unprotected. Score −8.
LOWPermissions-Policy header present with deny-all but allow= attributes on iframes use * wildcard for one or more features — effectively re-enabling denied features for all iframe origins. Score −6.

Implementation checklist

Run a SkillAudit scan to check your MCP server or Claude skill's Permissions-Policy configuration. The scanner verifies both the HTTP header and the allow= attributes on any iframes used for tool output rendering, and flags legacy Feature-Policy headers that provide false security confidence.