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 |
|---|---|---|---|
camera | Video capture via getUserMedia() | (self) | () |
microphone | Audio capture via getUserMedia() | (self) | () |
geolocation | navigator.geolocation.getCurrentPosition() | (self) | () |
payment | Payment Request API, Web Payments | (self) | () unless payment flow needed |
usb | WebUSB — enumerate and communicate with USB devices | (self) | () |
hid | WebHID — Human Interface Devices (gamepads, etc.) | (self) | () |
serial | Web Serial API — communicate with serial ports | (self) | () |
bluetooth | Web Bluetooth — connect to Bluetooth devices | (self) | () |
screen-wake-lock | Prevent screen from sleeping | (self) | () |
compute-pressure | Read CPU/GPU thermal and utilisation data | () | () |
ambient-light-sensor | Read ambient light levels | () | () |
accelerometer | Device motion — acceleration (physical attack leakage) | (self) | () |
gyroscope | Device orientation — gyroscope | (self) | () |
magnetometer | Compass 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:
- 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. - iframe's HTTP header: The cross-origin server serving the iframe's content sends its own
Permissions-Policyheader. This applies to the iframe's document. - 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.
MCP tool processes external URL — returns HTML with embedded JavaScript targeting navigator.mediaDevices.getUserMedia({video: true})
Client renders tool output — injects HTML into DOM or loads it in an iframe. Script executes.
getUserMedia() call hits browser API layer — browser checks the feature policy for the current browsing context before dispatching the OS permission prompt.
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 name | Feature-Policy | Permissions-Policy |
| Value syntax | Space-separated directives: camera 'none' | Structured field: camera=() |
| Deny syntax | 'none' keyword | Empty allowlist () |
| Self-only syntax | 'self' keyword | (self) |
| Wildcard syntax | * | * |
| iframe attribute | allow= (already used v2 syntax) | allow= |
| Browser support | Chrome 60–73 only; deprecated | Chrome 74+, Firefox 74+, Safari 16+ |
| Feature names | Some 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:
- 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 throwNotAllowedError. - curl:
curl -sI https://skillaudit.dev/ | grep -i permissions-policy— this checks what the server sends without any browser processing. - SecurityHeaders.com / Observatory: Third-party scanners check for the header and its completeness. They flag missing features and detect legacy
Feature-Policyheaders.
Permissions-Policy and Content Security Policy interaction
Permissions-Policy and CSP are complementary but non-overlapping controls:
- CSP controls what code can run (script-src), what resources can load (img-src, connect-src), and what iframes can be embedded (frame-src, frame-ancestors). CSP operates at the resource-loading and execution layer.
- Permissions-Policy controls which browser APIs the running code can call. Code that CSP allows to execute is still subject to Permissions-Policy on every API call.
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
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.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.Permissions-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.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.add_header Permissions-Policy ... without always — header absent on 4xx/5xx error responses. Error pages rendering tool output diagnostics are unprotected. Score −8.allow= attributes on iframes use * wildcard for one or more features — effectively re-enabling denied features for all iframe origins. Score −6.Implementation checklist
- Send
Permissions-Policyheader (notFeature-Policy) on all HTTP responses including 4xx/5xx - Deny camera, microphone, geolocation, payment, USB, HID, serial, Bluetooth by default
- Deny motion/sensor APIs: screen-wake-lock, compute-pressure, ambient-light-sensor, accelerometer, gyroscope, magnetometer
- Set explicit
allow=attributes on all<iframe>elements rendering tool output - Verify the header is sent on error pages (nginx: add
alwaysflag) - Test API denial by attempting
navigator.mediaDevices.getUserMedia()in console — should throw immediately - Check cross-origin iframes also send their own restrictive Permissions-Policy headers
- Remove any remaining legacy
Feature-Policyheaders
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.