Permissions-Policy: Lock Down Camera, Mic & Geo
A third-party script inside an iframe should never be able to turn on your users' camera, microphone or GPS just because your page embeds it. Permissions-Policy is the HTTP response header that makes that rule enforceabl
A third-party script inside an iframe should never be able to turn on your users' camera, microphone or GPS just because your page embeds it. Permissions-Policy is the HTTP response header that makes that rule enforceable in modern browsers. It is the successor to Feature-Policy, it is separate from Content-Security-Policy, and a single misconfigured allowlist is enough to re-open a sensor you thought you had closed.
I build and run PixelValut, a set of browser-side developer and security tools, and I lock these headers down on my own sites the same way I recommend here. I wrote this with help from AI tools and checked every command and claim myself. This article is specifically about Permissions-Policyβcamera, microphone, geolocation, payment, usb, fullscreen and how to verify the headerβnot a CSP deep-dive.
What does Permissions-Policy do for camera, mic and geo?
Direct answer: Permissions-Policy lets you declare, in an HTTP response header, which origins may use powerful browser features such as camera, microphone and geolocation. A deny-all default for sensors, with explicit allowlists only for trusted same-origin or partner origins, stops malicious iframes and injected scripts from calling those APIs even if they somehow execute on the page.
Header syntax in one pass
The modern header name is Permissions-Policy. Older docs and some servers still emit Feature-Policy with a different grammar. Prefer the new name and the allowlist syntax:
Permissions-Policy: camera=(), microphone=(), geolocation=(),
payment=(), usb=(), fullscreen=(self)
Meaning of the allowlist tokens:
| Token | Meaning |
|---|---|
* |
Any origin may use the feature (almost never what you want for sensors) |
() |
Empty allowlist β feature disabled for everyone, including your own origin |
(self) |
Only your origin |
(self "https://trusted.example") |
Your origin plus listed origins |
"https://trusted.example" |
Only that origin (no self) |
Feature names are lowercase. Common ones you should decide on deliberately:
| Feature | Risk if left open | Typical production default |
|---|---|---|
camera |
Covert video capture from iframe/script |
() unless you are a video product |
microphone |
Covert audio capture |
() unless you need WebRTC on this origin |
geolocation |
Silent location reads |
() or (self) for a maps feature you own |
payment |
Payment Request API abuse |
() or (self) on checkout only |
usb |
WebUSB device access | () |
fullscreen |
Phishing overlays via fullscreen |
(self) often enough |
display-capture |
Screen sharing |
() unless intentional |
clipboard-read |
Clipboard exfiltration |
() or (self)
|
Threat scenarios this header actually blocks
Malicious iframe. You embed a partner widget. The partner gets compromised and serves a script that calls navigator.mediaDevices.getUserMedia(). Without Permissions-Policy, the browser may still prompt the userβor, in nested contexts with prior grants, behave in ways you did not intend. With camera=() and microphone=() on your top-level response (and appropriate allow attributes on the iframe), the feature is unavailable in that document tree.
Third-party script on your origin. A tag manager or analytics bundle is not a substitute for an allowlist. Permissions-Policy is enforced by the browser against the document's policy regardless of which script file initiated the call. Combined with a tight script policy (and habits from cookie security flags and certificate controls like CAA records and CT logs), you reduce both exfiltration and sensor abuse.
Payment and USB surprise surfaces. Checkout pages sometimes load chat or upsell iframes. payment=() on the marketing site and payment=(self) only on the payment origin keeps Payment Request scoped. usb=() should be the default everywhere unless you ship a deliberate WebUSB tool.
Permissions-Policy does not replace CSP, SRI or cookie flags. It closes a different door: powerful Web APIs.
Deny-all vs allowlists: comparison
| Strategy | Example | Pros | Cons | When to use |
|---|---|---|---|---|
| Deny-all sensors | camera=(), microphone=(), geolocation=() |
Smallest attack surface | Breaks intentional WebRTC/maps | Marketing sites, docs, blogs, most SaaS admin UIs |
| Self-only | geolocation=(self) |
First-party maps/check-in work | Still exposed if XSS runs as you | Features you own on the same origin |
| Explicit partner | camera=(self "https://meet.example") |
Supports known embeds | Must maintain the list; typos fail open or closed depending on test coverage | Video vendors you trust |
Wildcard *
|
camera=* |
Convenient for demos | Any iframe can request the sensor | Never in production |
Fail closed for camera, microphone, geolocation, usb and payment unless a product requirement says otherwise. Fullscreen at (self) is a reasonable compromise for app shells.
Real configuration examples
Static marketing site (recommended default):
Permissions-Policy: accelerometer=(), camera=(), geolocation=(),
gyroscope=(), magnetometer=(), microphone=(), payment=(),
usb=(), interest-cohort=(), browsing-topics=()
SaaS app with first-party WebRTC on app.example.com:
Permissions-Policy: camera=(self), microphone=(self),
display-capture=(self), geolocation=(), payment=(),
usb=(), fullscreen=(self)
Page that embeds a trusted meeting vendor:
<iframe
src="https://meet.vendor.example/room/123"
allow="camera https://meet.vendor.example; microphone https://meet.vendor.example; fullscreen">
</iframe>
Permissions-Policy: camera=(self "https://meet.vendor.example"),
microphone=(self "https://meet.vendor.example"),
geolocation=(), payment=(), usb=()
The iframe allow attribute and the Permissions-Policy header must agree. The stricter side wins. Setting only one of them is a common production bug.
Nginx snippet:
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)" always;
Express:
app.use((req, res, next) => {
res.setHeader(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)"
);
next();
});
Verify with curl and DevTools
Check the live response:
curl -sI https://pixelvalut.com/tools/ | rg -i 'permissions-policy|feature-policy'
You want a Permissions-Policy line in the response headers. If you only see Feature-Policy, plan a migration. If you see neither, the browser falls back to defaults that are more permissive than a deny-all posture.
In Chrome or Edge DevTools:
- Open Network, reload, select the document request.
- Read Response Headers for
permissions-policy. - In the console, try
navigator.mediaDevices.getUserMedia({audio:true})on a page that setsmicrophone=()β you should see a policy error rather than a clean prompt flow. - For iframes, inspect the frame's
allowattribute and retest from inside that frame context.
I also hit pages through PixelValut's tools index and the HTTP status checker when I want a quick header read without opening the full browser stack. Confirm the header on the HTML document response, not only on APIs or static assetsβpolicy for features is document-scoped.
Rollout checklist
- Inventory which pages genuinely need camera, mic, geo, payment, usb, fullscreen or display-capture.
- Set a deny-all baseline on the edge (CDN or reverse proxy) for marketing and docs hosts.
-
Override per app host with
(self)or explicit partner origins where product requires it. -
Align iframe
allowattributes with the header; remove leftoverallow="camera; microphone"on untrusted embeds. - Add a CI or synthetic check that fails if production HTML responses drop the header.
- Test WebRTC and maps flows in staging with the real policy before launch.
-
Document the allowlist next to CSP notes so the next engineer does not "temporarily" set
*. - Re-verify after CDN changesβsome panels strip custom headers on cache rule edits.
Pre-deploy checklist
- [ ]
Permissions-Policypresent on HTML document responses - [ ]
camera,microphone,geolocation,usb,paymentdefault to()unless required - [ ] Trusted embeds listed explicitly; no
* - [ ] Iframe
allowattributes match the header - [ ] curl / DevTools verification recorded for staging and production
- [ ] Feature-Policy legacy header removed or kept intentionally in sync during migration
- [ ] Owners know which product flows depend on each exception
Frequently asked questions
Is Permissions-Policy the same as Feature-Policy?
No. Feature-Policy was the older header with a different syntax (camera 'none'). Browsers migrated to Permissions-Policy with allowlist syntax (camera=()). Ship the new header; treat Feature-Policy as legacy.
Does a strict Permissions-Policy replace CSP?
No. CSP controls what resources load and what scripts execute. Permissions-Policy controls which powerful APIs those scripts may call. You want both, plus cookie flags and certificate controls where relevant.
What happens if the header is missing?
The browser uses its default feature availability. Many sensors still require a user prompt, but embedded contexts and previously granted permissions can surprise you. Missing policy is not the same as deny-all.
Can users override my policy?
Users control prompts and site settings, but they cannot make your page grant an iframe a feature your Permissions-Policy forbids. The header is an upper bound.
Should every subdomain share one policy?
Not always. app. may need camera for support calls while www. should deny it. Set policy at the host that serves the HTML, and avoid copying a loose app policy onto the marketing site.
How do I test without breaking production WebRTC?
Ship deny-all on hosts that never need sensors. On the WebRTC host, use (self) in staging, run call tests, then promote. Keep partner origins in config, not hard-coded in a one-off nginx edit.
Follow & Connect
- YouTube: Pixel Valut (@pixel-valut)
- TikTok: PixelValut (@pixelvalut007)
- GitHub: Muhammad Ali Mughal (@ali111887)
- LinkedIn: Muhammad Ali Mughal
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.