Remediation guide

How to Set a Permissions-Policy Header (and Which Features to Disable)

Your site probably doesn’t need camera access, microphone access, or the ability to request the user’s location — but without a Permissions-Policy header, any script running on your page, and any iframe you embed, can ask the browser for those APIs. One compromised dependency or a third-party ad iframe is all it takes. A single header tells the browser exactly which features you’ve intentionally enabled, and blocks everything else at the browser level — before any JavaScript even runs.

What Permissions-Policy controls

The Permissions-Policy response header (formerly Feature-Policy, renamed in 2020) is a declarative allowlist for browser APIs that carry privacy or security risk. When a browser feature appears in the policy with an empty allowlist — written () — the browser refuses any attempt to use that feature on your page, including from third-party scripts and iframes. When a feature is absent from the policy, default browser behaviour applies (which for most features means the browser’s own permission prompt still governs access).

The APIs Permissions-Policy governs are not obscure. They include:

What gets controlledWhy it matters
camera, microphone Physical device access. A third-party script that activates these triggers no extra browser prompt if the user previously granted your origin permission.
geolocation Precise location. Previously-granted permissions can be used by any script on the page, not just yours.
payment The Payment Request API. Should be locked to (self) on any page that uses it; disabled everywhere else.
usb, hid, serial Hardware API access. Rarely needed; high impact if misused.
display-capture Screen capture via getDisplayMedia(). A tool for legitimate conferencing apps; a powerful exfiltration vector if abused.
clipboard-read Read whatever the user has copied — passwords, 2FA codes, confidential text — without a visible prompt in some contexts.

Setting a Permissions-Policy header does not replace browser permission prompts. It is a server-side ceiling: even if the user would be willing to grant access, a feature blocked by Permissions-Policy will be denied unconditionally. This makes it effective as a defence against supply-chain attacks: a compromised npm package that calls navigator.mediaDevices.getUserMedia() will get a NotAllowedError if camera=() is set, without any user interaction required.

How the allowlist syntax works

Each directive takes an allowlist in parentheses. There are three values to know:

SyntaxMeaning
feature=() Deny all. The feature is blocked for your origin and every iframe you embed. This is the right default for any feature you don’t use.
feature=(self) Allow same origin only. Your pages can use the feature; third-party iframes cannot unless you explicitly grant it via the allow attribute.
feature=* Allow all origins. Equivalent to no restriction. Only use this if you explicitly want iframes from any origin to access the feature.

You can also allow specific third-party origins by listing them:

Permissions-Policy: camera=(self "https://video.example.com")

This allows camera access on your own pages and in iframes from video.example.com, and denies it everywhere else.

Multiple directives are separated by commas:

Permissions-Policy: camera=(), microphone=(), geolocation=(self)
Feature-Policy syntax is different and deprecated. The old header used a different format: Feature-Policy: camera 'none'; geolocation 'self' — note single quotes around the values, no parentheses, and semicolon separators. Chrome dropped Feature-Policy support in Chrome 88 (January 2021). Set only Permissions-Policy; the old header does nothing on modern browsers and adding it alongside the new one creates a maintenance burden with no benefit.

A safe baseline policy

The following policy disables every high-risk browser API. It is safe to deploy on any site that does not use video calling, geolocation, hardware access, or screen capture. Check the list of directives in the next section before deploying if you’re unsure whether your site uses any of these features.

Permissions-Policy:
  camera=(),
  microphone=(),
  geolocation=(),
  payment=(),
  usb=(),
  display-capture=(),
  clipboard-read=(),
  hid=(),
  serial=()

Written as a single header line (required by most web servers):

Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), display-capture=(), clipboard-read=(), hid=(), serial=()
Start with what you actually use. Before shipping the baseline, audit your code for calls to navigator.geolocation, navigator.mediaDevices, navigator.clipboard.readText(), and the Payment Request API. If you use geolocation, change geolocation=() to geolocation=(self). The baseline is designed to be safe by default, not to break anything — but verifying against your actual feature usage takes two minutes and prevents a confusing regression.

Common directives explained

These are the directives HardenCheck evaluates and the ones most commonly flagged by security scanners:

camera and microphone

Both call navigator.mediaDevices.getUserMedia(). If your site has no video calling, no voice recording, and no QR scanner, set both to (). If you use them on specific pages (e.g., a video consultation page), set (self) and ensure only that page triggers the prompt.

geolocation

Calls navigator.geolocation.getCurrentPosition() or watchPosition(). If your site shows maps or location-based results, set (self). If it does not, set (). Most marketing and SaaS sites do not need geolocation at all.

payment

Controls the Payment Request API — the browser-native checkout flow (Apple Pay, Google Pay via the browser). This is distinct from client-side libraries like Stripe.js, which do not require this permission. Set () unless you are explicitly using the Payment Request API.

display-capture

Controls navigator.mediaDevices.getDisplayMedia() — screen sharing. Used by conferencing tools. Set () on any site that is not a video conferencing application.

clipboard-read and clipboard-write

clipboard-read covers navigator.clipboard.readText() and readClipboardItems(). This is the sensitive one: reading the clipboard can capture whatever the user last copied — a password, a 2FA code, a private document snippet. Set () unless you have a genuine use case (e.g., a password manager or rich-text editor that pastes formatted content). clipboard-write controls writeText() — writing to the clipboard. This is lower risk and commonly needed for “copy to clipboard” buttons. Set (self) if you use copy buttons.

fullscreen

Controls Element.requestFullscreen(). Unlike the other directives, the default value for fullscreen is (self) in most browsers — meaning it is already restricted to the same origin without an explicit header. Setting fullscreen=() blocks it entirely, including for your own pages. Only set it to () if your site has no full-screen video players or image viewers.

Permissions-Policy and iframes

The header sets a ceiling for your entire origin. An iframe can only access a feature if both of these are true:

  1. Your site’s Permissions-Policy header allows the feature for the iframe’s origin (or uses *)
  2. The <iframe> element has an allow attribute that grants the feature

This means you can set a restrictive site-wide policy while giving specific trusted iframes access. For example, if you embed a Zoom widget that needs camera access:

<!-- Site-wide header: Permissions-Policy: camera=("https://zoom.us") -->
<iframe src="https://zoom.us/wc/12345/join"
        allow="camera; microphone"></iframe>

The allow attribute on the iframe is a request; the header on the parent page is the grant. If the header doesn’t allow the feature for that origin, the allow attribute is ignored.

Embedded third-party widgets often request camera or microphone access. Chat widgets, video widgets, and some analytics embeds use iframes that may call browser APIs. If you embed any third-party widget in an iframe and that widget stops working after you deploy Permissions-Policy, check whether it needs a feature you’ve blocked. Add the specific origin to the allowlist for that feature rather than widening the policy.

Deploy it on your platform

nginx

add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), display-capture=(), clipboard-read=()" always;

Apache (.htaccess)

<IfModule mod_headers.c>
  Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), display-capture=(), clipboard-read=()"
</IfModule>

Vercel (vercel.json)

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Permissions-Policy",
          "value": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), display-capture=(), clipboard-read=()"
        }
      ]
    }
  ]
}

Next.js (next.config.js)

const securityHeaders = [
  {
    key: 'Permissions-Policy',
    value: 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), display-capture=(), clipboard-read=()'
  }
];

module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: securityHeaders,
      },
    ];
  },
};

Netlify (_headers file)

/*
  Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), display-capture=(), clipboard-read=()

Cloudflare Pages (_headers file)

/*
  Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), display-capture=(), clipboard-read=()

Express.js

app.use((req, res, next) => {
  res.setHeader(
    'Permissions-Policy',
    'camera=(), microphone=(), geolocation=(), payment=(), usb=(), display-capture=(), clipboard-read=()'
  );
  next();
});

If you use the Helmet middleware, it does not set Permissions-Policy by default. Add it with the permissionsPolicy option (available in Helmet 7+):

app.use(helmet({
  permissionsPolicy: {
    policy: {
      camera: [],
      microphone: [],
      geolocation: [],
      payment: [],
      usb: [],
      'display-capture': [],
      'clipboard-read': []
    }
  }
}));

An empty array ([]) in Helmet’s API maps to () in the header value — deny all.

Verify with HardenCheck

After deploying your Permissions-Policy header, paste your raw response headers into HardenCheck. It checks whether the header is present, whether the syntax is valid structured-fields format (the new feature=() syntax, not the deprecated feature 'none' syntax), and whether the high-risk features — camera, microphone, geolocation — are restricted. You can test different policy values by editing the paste directly; the grade updates immediately.

Grade my headers with HardenCheck →

Frequently asked questions

What is the difference between Feature-Policy and Permissions-Policy?

Feature-Policy was the original name, introduced around 2016–2018. It was renamed to Permissions-Policy in 2020 as the specification was standardised. The two headers use different syntax: Feature-Policy used space-separated values with single-quoted keywords (Feature-Policy: camera 'none'; geolocation 'self'), while Permissions-Policy uses a structured-fields format with parentheses (Permissions-Policy: camera=(), geolocation=(self)). Chrome 88+ (January 2021) and other modern browsers support only the new syntax. Set only Permissions-Policy; the old header does nothing on current browsers.

What does () mean in a Permissions-Policy directive?

An empty allowlist — () — means the feature is denied for all origins, including your own site. Any script on your page that calls the blocked API will receive a NotAllowedError, and any iframe you embed will also be denied, regardless of its allow attribute. This is the correct default for any browser feature your site does not actively use. Compare with (self), which allows the feature on your own origin but denies it in third-party iframes unless you explicitly grant it.

Can an iframe override a Permissions-Policy set by the parent?

No. The parent page’s header acts as a ceiling. If camera=() is set, even an iframe with allow="camera" in its markup will be denied camera access — the header wins over the attribute. An iframe can only use a feature if the parent’s policy header allows it for that origin and the allow attribute grants it. You can use this to allow specific trusted iframes while keeping the site-wide default restrictive: set camera=(self "https://trusted-embed.com") in the header and add allow="camera" only to that specific iframe.

Will setting Permissions-Policy break my site’s existing features?

Only if you deny a feature your site actively uses. Before deploying, check whether any page calls navigator.geolocation.getCurrentPosition(), navigator.mediaDevices.getUserMedia(), or the Payment Request API. If yes, set that feature to (self) rather than (). The safe approach is to deploy to a staging environment first, test your user flows, and verify that no feature-not-allowed errors appear in the browser console before shipping to production.

Do I need to set Permissions-Policy for features I don’t use?

Yes — that is exactly the point of the header. Without it, browser APIs are governed only by permission prompts. A third-party script injected via a compromised dependency, or an iframe you embed (an ad, a widget), can request access to camera or geolocation and the user might grant it, thinking it’s part of your app. Setting camera=() blocks that API unconditionally at the browser level, before any JavaScript runs. It is a one-line hardening that is low-effort and high-value for any site that does not use those features.

How do I allow a feature for one page but not the whole site?

The HTTP header applies to the entire origin. You cannot scope it to a single path. For page-level exceptions, the practical approach is: set a restrictive site-wide header, host the feature-using functionality on a dedicated subdomain with its own permissive header, or use a service worker to rewrite the header on that specific path. For iframe exceptions, the per-element allow attribute on the <iframe> tag gives you granular control: set geolocation=() in the header and add allow="geolocation" only to the specific iframes that need it.

Does Permissions-Policy affect JavaScript APIs like navigator.clipboard?

Yes. clipboard-read and clipboard-write control navigator.clipboard.readText() and writeText() respectively. If you set clipboard-read=() and a script calls clipboard.readText(), it will receive a NotAllowedError. Sites that use “copy to clipboard” buttons should set clipboard-write=(self) rather than (). Sites that never read from the clipboard (which is most sites) should set clipboard-read=(), since reading the clipboard without explicit user interaction is a common exfiltration pattern.

Also in the Copper Bay Labs ship-safety suite

Permissions-Policy is one of several security headers HardenCheck checks — here are guides for the others: