Permissions-Policy Headers: Lock Down Your Browser Features Before Someone Else Does

Every browser tab your visitors open is a potential attack surface. Without explicit constraints, JavaScript running on your page — whether yours, a third-party widget’s, or injected malware — can silently access the camera, microphone, geolocation, full-screen API, USB devices, and a dozen other sensitive capabilities. And by default, most of it is allowed.

Permissions-Policy is the HTTP header that fixes this. It lets you declare, server-side, exactly which browser features are permitted on your origin — and which are off-limits, even for embedded iframes. Think of it as a firewall rule for browser APIs.

Most developers know about Content-Security-Policy. Far fewer bother with Permissions-Policy, which is a shame, because skipping it leaves a whole class of browser-level attacks completely unaddressed.

This article walks through the full picture: what the header controls, how to write policies that actually work, and how to deploy them across the major web servers and application frameworks. There are real gotchas here that will bite you if you go in blind.


The Rename That Confused Everyone

Permissions-Policy replaced Feature-Policy in Chrome 88 (January 2021). The syntax changed substantially — old Feature-Policy used a space-separated string with allow, *, none, and origin lists, while Permissions-Policy uses a structured-fields syntax with = and parenthesised origin lists.

If you still have Feature-Policy in your headers, it works in some browsers for backward compatibility, but it’s deprecated and ignored in others. You need both if you’re supporting a legacy browser matrix, but for anything modern, Permissions-Policy is the one that counts.


What It Actually Controls

The header governs browser features and APIs — not content, not scripts, but the underlying capabilities the browser exposes. At the time of writing, the major directives include:

Sensors and hardware

  • camera — access to video input devices
  • microphone — audio capture
  • geolocationnavigator.geolocation
  • accelerometer, gyroscope, magnetometer — device motion sensors
  • usb — WebUSB API
  • bluetooth — Web Bluetooth
  • serial — Web Serial API
  • midi — Web MIDI

Display and UI

  • fullscreenElement.requestFullscreen()
  • picture-in-picture
  • display-capturegetDisplayMedia() screen sharing
  • xr-spatial-tracking — WebXR

Powerful APIs

  • payment — Payment Request API
  • publickey-credentials-get — WebAuthn
  • clipboard-read, clipboard-write
  • hid — Human Interface Devices
  • idle-detection
  • local-fonts
  • window-management — multi-screen window placement

Privacy-sensitive

  • interest-cohort — the old FLoC API (still worth blocking explicitly)
  • browsing-topics — Google’s Topics API (ad targeting)
  • attribution-reporting
  • join-ad-interest-group, run-ad-auction — Privacy Sandbox auction APIs

That last category is the one most developers miss. You might not want your site silently participating in ad-targeting APIs you never asked for.


The Syntax

The structured-fields format looks like this:

Permissions-Policy: camera=(), microphone=(), geolocation=(self)

Each directive takes an allowlist in parentheses:

Value Meaning
() Blocked everywhere — this origin and all iframes
(self) Allowed only on your origin
* Allowed anywhere (the dangerous default for most APIs)
("https://example.com") Allowed on your origin and that specific third-party
(self "https://cdn.example.com") Your origin plus one trusted third party

The key mental model: if you don’t mention a directive, the browser uses its own default — which for most powerful APIs is * (allowed everywhere). Explicit empty parens () is your "hard no".


Writing Your Baseline Policy

Start with the principle of least privilege. Block everything you don’t actively use, allow self for features you do use, and explicitly whitelist trusted embeds.

Here’s a solid baseline for a typical content/SaaS site that doesn’t use cameras, GPS, or hardware APIs:

Permissions-Policy:
  accelerometer=(),
  ambient-light-sensor=(),
  attribution-reporting=(),
  autoplay=(self),
  bluetooth=(),
  browsing-topics=(),
  camera=(),
  clipboard-read=(),
  clipboard-write=(self),
  display-capture=(),
  encrypted-media=(self),
  fullscreen=(self),
  geolocation=(),
  gyroscope=(),
  hid=(),
  identity-credentials-get=(),
  idle-detection=(),
  interest-cohort=(),
  join-ad-interest-group=(),
  keyboard-map=(),
  local-fonts=(self),
  magnetometer=(),
  microphone=(),
  midi=(),
  payment=(),
  picture-in-picture=(self),
  publickey-credentials-create=(self),
  publickey-credentials-get=(self),
  run-ad-auction=(),
  screen-wake-lock=(),
  serial=(),
  speaker-selection=(),
  storage-access=(),
  usb=(),
  web-share=(self),
  window-management=(),
  xr-spatial-tracking=()

Yes, it’s long. That’s fine — a comprehensive deny-list is the point.


Nginx Configuration

Drop this into your server {} block or a shared security_headers.conf include:

# /etc/nginx/snippets/security_headers.conf

add_header Permissions-Policy "
  accelerometer=(),
  ambient-light-sensor=(),
  attribution-reporting=(),
  autoplay=(self),
  bluetooth=(),
  browsing-topics=(),
  camera=(),
  clipboard-read=(),
  clipboard-write=(self),
  display-capture=(),
  encrypted-media=(self),
  fullscreen=(self),
  geolocation=(),
  gyroscope=(),
  hid=(),
  idle-detection=(),
  interest-cohort=(),
  join-ad-interest-group=(),
  local-fonts=(self),
  magnetometer=(),
  microphone=(),
  midi=(),
  payment=(),
  picture-in-picture=(self),
  publickey-credentials-create=(self),
  publickey-credentials-get=(self),
  run-ad-auction=(),
  screen-wake-lock=(),
  serial=(),
  storage-access=(),
  usb=(),
  web-share=(self),
  window-management=(),
  xr-spatial-tracking=()" always;

Then in your server block:

server {
    listen 443 ssl;
    server_name example.com;

    include snippets/security_headers.conf;

    # ... rest of your config
}

Gotcha: Nginx add_header directives in a parent block are not inherited if the child block also has add_header. If your location /api {} block sets any header, it silently drops all headers from the server block. Use the always flag and either repeat headers in every block or use the headers_more module, or structure your config to avoid the inheritance trap entirely.


Apache Configuration

For Apache, you need mod_headers enabled (a2enmod headers):

# /etc/apache2/conf-available/security-headers.conf
<IfModule mod_headers.c>
    Header always set Permissions-Policy "\
accelerometer=(), \
ambient-light-sensor=(), \
attribution-reporting=(), \
autoplay=(self), \
bluetooth=(), \
browsing-topics=(), \
camera=(), \
clipboard-read=(), \
clipboard-write=(self), \
display-capture=(), \
encrypted-media=(self), \
fullscreen=(self), \
geolocation=(), \
gyroscope=(), \
hid=(), \
idle-detection=(), \
interest-cohort=(), \
join-ad-interest-group=(), \
local-fonts=(self), \
magnetometer=(), \
microphone=(), \
midi=(), \
payment=(), \
picture-in-picture=(self), \
publickey-credentials-create=(self), \
publickey-credentials-get=(self), \
run-ad-auction=(), \
screen-wake-lock=(), \
serial=(), \
storage-access=(), \
usb=(), \
web-share=(self), \
window-management=(), \
xr-spatial-tracking=()"
</IfModule>

Enable it:

a2enconf security-headers
systemctl reload apache2

Gotcha: The backslash line continuations in Apache config are sensitive to trailing spaces. If you have a space after \, the header value breaks silently and you end up with a malformed string. Test with curl -I https://example.com and eyeball the output.


Caddy Configuration

Caddy is the cleanest to configure here. Use the header directive:

example.com {
    header {
        Permissions-Policy "accelerometer=(), ambient-light-sensor=(), attribution-reporting=(), autoplay=(self), bluetooth=(), browsing-topics=(), camera=(), clipboard-read=(), clipboard-write=(self), display-capture=(), encrypted-media=(self), fullscreen=(self), geolocation=(), gyroscope=(), hid=(), idle-detection=(), interest-cohort=(), join-ad-interest-group=(), local-fonts=(self), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(self), publickey-credentials-create=(self), publickey-credentials-get=(self), run-ad-auction=(), screen-wake-lock=(), serial=(), storage-access=(), usb=(), web-share=(self), window-management=(), xr-spatial-tracking=()"
    }

    reverse_proxy localhost:3000
}

Caddy doesn’t have the inheritance gotcha that Nginx does, so one header block at the site level covers all routes.


Node.js / Express

Use helmet — it ships a permissionsPolicy middleware:

import helmet from 'helmet';

app.use(
  helmet.permissionsPolicy({
    features: {
      accelerometer:            [],  // () — blocked
      ambientLightSensor:       [],
      attributionReporting:     [],
      autoplay:                 ['self'],
      bluetooth:                [],
      browsingTopics:           [],
      camera:                   [],
      clipboardRead:            [],
      clipboardWrite:           ['self'],
      displayCapture:           [],
      encryptedMedia:           ['self'],
      fullscreen:               ['self'],
      geolocation:              [],
      gyroscope:                [],
      hid:                      [],
      idleDetection:            [],
      interestCohort:           [],
      joinAdInterestGroup:      [],
      localFonts:               ['self'],
      magnetometer:             [],
      microphone:               [],
      midi:                     [],
      payment:                  [],
      pictureInPicture:         ['self'],
      publickeyCredentialsCreate: ['self'],
      publickeyCredentialsGet:  ['self'],
      runAdAuction:             [],
      screenWakeLock:           [],
      serial:                   [],
      storageAccess:            [],
      usb:                      [],
      webShare:                 ['self'],
      windowManagement:         [],
      xrSpatialTracking:        [],
    },
  })
);

Gotcha with helmet: The feature names in helmet’s API are camelCase, but the generated header uses the correct kebab-case. If a directive isn’t in helmet’s known list, it silently drops it. Always verify the output header matches your intent — curl -sI https://cd-linux.club:3000 | grep -i permissions.

For Django, use django-csp or set it manually in middleware. For Laravel, the fruitcake/laravel-cors package or a custom middleware class work well. The pattern is the same regardless of framework: set the header on every response.


The allow Attribute on Iframes

The HTTP header controls what your page and its same-origin frames can do. But for cross-origin iframes, you also control things through the allow attribute:

<!-- YouTube embed — allow only what it needs -->
<iframe
  src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"
  allow="fullscreen; encrypted-media"
  sandbox="allow-scripts allow-same-origin"
></iframe>

Without allow, the iframe inherits the parent page’s policy. If you’ve blocked fullscreen=() at the header level, even allow="fullscreen" on the iframe won’t override it — the header wins. The iframe attribute can only grant permissions the parent page itself has. This is the delegation model: you can delegate to a child what you have, but you can’t grant what you don’t have.

Gotcha: Many developers add allow="camera; microphone" to a third-party chat widget iframe without realising those capabilities now need to be in the page-level header too (camera=(self "https://chat.example.com")), otherwise the iframe gets nothing regardless of its allow attribute.


Testing and Validation

Three tools worth bookmarking:

securityheaders.com — paste your URL, get an instant report card. Highlights missing headers, grades your policy, and catches structural errors in the header value.

Chrome DevTools — open the Application tab, then Permissions Policy. It shows every directive, whether it’s allowed or blocked, and which frame is responsible for each decision. Genuinely useful when debugging why your YouTube embed stopped going fullscreen.

curl for quick verification:

# Check headers from the outside
curl -sI https://example.com | grep -i permissions-policy

# Verify the header is present on API routes too
curl -sI https://example.com/api/users | grep -i permissions

Run your policy through the W3C validator at https://www.w3.org/TR/permissions-policy-1/ if you’re crafting complex multi-origin allowlists — the structured-fields format is strict and a single stray character will make the browser silently ignore the entire header.


Common Gotchas, Collected

The header has no effect on HTTP. Browsers only enforce Permissions-Policy over HTTPS (and localhost). You can set the header on an HTTP server for testing, but it won’t actually block anything in production if you’re running plain HTTP.

One malformed directive invalidates the whole header. Unlike CSP, where an invalid directive is skipped and the rest applies, a parse error in Permissions-Policy can cause browsers to drop the entire header. Test before deploying.

* is the implicit default, not the explicit one. Omitting a directive doesn’t mean you’ve blocked it — it means you’ve left it at the browser’s default, which for most powerful APIs is permissive. Explicit () is the only hard block.

Blocking interest-cohort doesn’t fully opt out of Privacy Sandbox. Google’s Privacy Sandbox APIs have expanded since FLoC was killed. Block browsing-topics, attribution-reporting, join-ad-interest-group, and run-ad-auction explicitly if you want a clean opt-out.

CDN caches can serve stale headers. If you push a policy update and it doesn’t seem to take effect, check whether your CDN has cached the old response. Purge the cache or set short TTLs on HTML responses.

Load balancers and reverse proxies can strip or duplicate headers. If your app sets Permissions-Policy and your Nginx upstream also sets it, you’ll end up with two header lines — and browser behavior with duplicate Permissions-Policy headers is implementation-defined (generally the most restrictive wins, but don’t rely on it). Set the header in one place only.


Production Checklist

Before shipping:

  • Permissions-Policy header present on HTML responses (text/html)
  • Header also present on API responses if those responses are rendered in-browser
  • No duplicate header from multiple layers (app + proxy)
  • Tested with curl -I and securityheaders.com
  • Verified iframes have matching allow attributes for any delegated features
  • Privacy Sandbox APIs (browsing-topics, attribution-reporting, join-ad-interest-group, run-ad-auction) explicitly blocked unless you’re an ad tech platform
  • Policy reviewed whenever a new third-party embed is added
  • Monitoring in place for header presence (synthetic check in your uptime tool)

Where This Fits in Your Security Stack

Permissions-Policy is not a replacement for CSP, HSTS, X-Frame-Options, or Referrer-Policy — it’s a separate layer of the defense-in-depth stack. Each header addresses a different threat model:

  • CSP controls what scripts, styles, and resources can load
  • HSTS forces HTTPS
  • X-Frame-Options / frame-ancestors prevents clickjacking
  • Referrer-Policy limits what URL metadata leaks to third parties
  • Permissions-Policy locks down what browser APIs your page and its embeds can invoke

Get all five right and you’ve meaningfully reduced the blast radius of a successful XSS, a compromised third-party script, or a malicious iframe injection. Skip any one of them and you’ve left a clean vector open.

The browser feature landscape expands constantly — new Privacy Sandbox APIs, WebGPU, the WebTransport API — so treat your policy as a living document. Audit it whenever you add third-party embeds, whenever Chrome ships a new powerful API, and whenever a dependency update pulls in a new SDK that might be quietly requesting capabilities you never intended to grant.

Leave a comment

👁 Views: 2,285 · Unique visitors: 1,642