Disabling Camera, Microphone, Geolocation and Other Features with Permissions-Policy
This guide sets a deny-by-default Permissions-Policy header so powerful browser APIs — camera, microphone, geolocation, payment — cannot be invoked by your page or by anything embedded in it. The header’s history, the deprecated Feature-Policy it replaces, and its relationship to privacy controls live in the Referrer-Policy and Permissions-Policy reference; here the focus is the exact allowlist syntax, per-feature values, and how delegation to iframes works.
Configuration Syntax & Exact Values
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)
The value is a comma-separated list of feature=allowlist directives. The allowlist is a space-separated set of origins inside parentheses:
camera=()— an empty allowlist blocks the feature for all origins, including your own top-level document.getUserMedia({video:true})rejects with aNotAllowedError. Use this for any feature your site never legitimately uses.microphone=()— same empty-allowlist block applied to audio capture.geolocation=()— blocksnavigator.geolocation; calls fail immediately rather than prompting.payment=()— disables the Payment Request API for the document and all frames.usb=()— disables WebUSB.fullscreen=(self)—(self)allows the feature for the same-origin top-level document and same-origin frames only, while denying cross-origin embeds. This keeps video players working without granting fullscreen to third-party iframes.
The allowlist tokens:
()— block all. No origin, not even your own, may use the feature.(self)— allow the current origin (and same-origin frames) only.(self "https://x.example")— allow your origin plus the explicitly listed cross-origin embed. Origins are space-separated and each cross-origin value is quoted.*— allow every origin (rarely correct for sensitive features; never combine withself, as*already includes it).
Unquoted origins, commas inside an allowlist, or the legacy 'none'/'self' quoting from Feature-Policy are malformed and cause the directive to be ignored. A feature you omit entirely falls back to its browser default, which for most capture APIs is (self) — so to truly block a feature you must list it explicitly with ().
Server-Side Configuration
Emit the header once at the outermost layer. A second copy from a CDN or app layer creates a duplicate that browsers may treat unpredictably.
Nginx
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)" always;
always forces emission on 4xx/5xx responses too; without it the header is dropped from error pages. An add_header in a location block replaces inherited headers, so repeat the directive in any location that sets its own.
Apache
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)"
always appends the header even on internally generated error documents, which the default onsuccess table skips. Requires mod_headers (a2enmod headers).
Cloudflare
Use a Transform Rule (Rules → Transform Rules → Modify Response Header) → Set static → header name Permissions-Policy, value camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self). The rule runs at the edge on matched responses; remove any duplicate origin add_header so only one copy ships.
Node/Helmet
Helmet does not ship a Permissions-Policy helper, so set it directly:
app.use((req, res, next) => {
res.setHeader(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)'
);
next();
});
Register this middleware before route and error handlers so short-circuited responses still carry the header.
Diagnostic & Verification Steps
Confirm the exact wire value:
curl -sI https://yourdomain.com | grep -i permissions-policy
Expected output:
permissions-policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)
Query a feature from the page to confirm it is blocked. In DevTools Console on your origin:
navigator.permissions.query({ name: 'camera' }).then(s => console.log(s.state));
Expected output: denied for a feature you set to (). Calling the API directly is the definitive test — navigator.geolocation.getCurrentPosition() on a blocked origin invokes the error callback with a PERMISSION_DENIED code and never prompts the user.
Check iframe delegation. A cross-origin iframe that needs a feature must be granted it both by your header (listing its origin) and by the frame’s allow attribute:
<iframe src="https://maps.example/widget" allow="geolocation"></iframe>
If the parent header is geolocation=(), the frame is denied regardless of its allow attribute. DevTools → the iframe’s request → Console shows: Permissions policy violation: geolocation is not allowed in this document.
Browser DevTools: the Application panel does not list this header, so verify in the Network tab → document request → Response Headers, and confirm there is no duplicate Permissions-Policy line.
Edge Cases, Security Implications & Safe Rollback
- Delegating to a legitimate iframe needs two grants. To let an embedded map use location, your header must read
geolocation=(self "https://maps.example")and the iframe tag must carryallow="geolocation". The header alone is the gate; theallowattribute is the per-frame opt-in. Omitting either keeps the feature blocked. This is the most common reason a “correctly configured” embed still fails. - Accessibility and media impact. A blanket
fullscreen=()breaks the fullscreen button on video players and slide decks; usefullscreen=(self)so first-party media keeps working while third-party frames stay locked down. Similarly,microphone=()silently disables dictation and accessibility tooling that relies on the Web Speech API — confirm no first-party assistive feature needs it before blocking. - Legacy Feature-Policy. The deprecated
Feature-Policyheader used a different grammar (geolocation 'self', no parentheses, quoted keywords). Modern browsers ignore it in favor ofPermissions-Policy. Do not serve both with conflicting intent; removeFeature-PolicyoncePermissions-Policyis in place, and never mix the two grammars in one header.
Rollback directive. This header is non-destructive and instantly reversible — there is no caching like HSTS. To restore a feature, change its directive from () to (self) (or add the embed origin) and redeploy; the next response takes effect immediately with no client-side persistence.
Frequently Asked Questions
What is the difference between camera=() and omitting the camera directive?
camera=() explicitly blocks the feature for all origins. Omitting it falls back to the browser default, which for capture APIs is usually (self) — meaning your own origin can still prompt for it. To guarantee a feature is off, list it with an empty allowlist.
Why does my embedded map still get geolocation denied after I allowlisted its origin?
The header is only one of two required grants. The iframe tag must also carry allow="geolocation". Both the parent header allowlist and the frame’s allow attribute must include the feature, or the embed stays blocked.
Do I still need the old Feature-Policy header?
No. Modern browsers ignore Feature-Policy when Permissions-Policy is present. Remove it to avoid serving two headers with different grammars and conflicting intent.
Is blocking these features reversible without affecting cached clients? Yes. Unlike HSTS, this header is not cached as a persistent policy. Changing a directive and redeploying takes effect on the very next response.
Conclusion
Start in staging with a deny-by-default value — camera=(), microphone=(), geolocation=(), payment=() — and exercise every first-party flow plus assistive tooling to confirm nothing legitimate breaks. Relax individual features to (self) only where your own pages need them, and add quoted embed origins plus the matching iframe allow attribute only for vetted third parties. Because the header carries no client-side persistence, you can tighten or loosen it in production with an immediate, fully reversible deploy.