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 ().
How Far Each Token Reaches
The four canonical allowlist forms map onto four concentric audiences: your own top-level document, your same-origin frames, a specific cross-origin embed you named, and every other origin on the web. Reading a directive is a matter of asking which of those four groups the token lets past the gate. The empty allowlist () admits none of them — not even the document that shipped the header — which is why it is the correct value for any capability your product genuinely never uses. (self) opens the gate for your own document and any frame that shares its origin, but stops there; a same-origin frame counts as self, a cross-origin frame does not. Adding a quoted origin widens the circle by exactly one named embed, and * throws it open to everyone, including origins you have never heard of.
In practice this means you rarely reach for *. A sensitive feature is either off everywhere (()), on for your own surfaces ((self)), or delegated to one vetted partner ((self "https://partner.example")). Treat * on camera, microphone, geolocation, or payment as a red flag in review: it hands a powerful capability to any origin that can get itself embedded in your page.
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.
Troubleshooting a Denied Feature
When a feature refuses to run, the failure resolves into one of three distinct states, and each has a different fix. Diagnosing quickly means identifying which state you are in rather than guessing. First, the feature may be explicitly blocked because your header lists it as feature=() — this is a working configuration doing exactly what you asked, and the fix is to relax the token, not to debug anything. Second, the feature may be omitted from the header entirely, in which case the browser default (usually (self)) governs it: your own top-level document keeps working while embedded frames silently lose access, which surprises teams who assumed “no directive” meant “no restriction”. Third — the most common real-world case — a cross-origin iframe is being denied because only one of the two required grants is present.
To distinguish the states in seconds, read the wire value with the curl command above. If the feature appears with (), you are in the first state. If it is absent from the header, you are in the second — add an explicit directive if you want frames to inherit access. If the feature is present and allowlists the embed’s origin yet the frame still fails, inspect the iframe tag: a missing or misspelled allow attribute is the third state, and the Console message Permissions policy violation: <feature> is not allowed in this document confirms it. Only after you know the state should you edit the header, because two of the three “failures” are the policy doing its job.
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.