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:

The allowlist tokens:

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.

Allowlist token reach comparisonA matrix showing which of four origin groups each Permissions-Policy allowlist token grants a feature to. Token Own top document Same-origin frame Listed cross-origin Other cross-origin ( ) block block block block (self) allow allow block block (self "x") allow allow allow block * allow allow allow allow Empty () blocks even your own document; * is the only token that reaches unnamed third parties.
Each token admits a wider ring of origins. The listed column corresponds to the quoted origin in "x"; unnamed cross-origin frames are reached only by *.

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.

Denied feature troubleshooting treeA decision tree mapping the three states that cause a Permissions-Policy feature to be denied to their respective fixes. Feature denied — why? Header sets feature=() explicit block Feature omitted from the header Cross-origin iframe needs delegation Working as intended. Relax () to (self) or add an origin. Default applies. Own origin works; frames blocked. Needs header origin AND allow= attr — both, or denied. Most "broken" embeds are the third state: one of the two grants is missing.
Three states produce a denied feature. Identify which one applies before changing anything — two of the three are the header behaving correctly.

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

  1. 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 carry allow="geolocation". The header alone is the gate; the allow attribute is the per-frame opt-in. Omitting either keeps the feature blocked. This is the most common reason a “correctly configured” embed still fails.
  2. Accessibility and media impact. A blanket fullscreen=() breaks the fullscreen button on video players and slide decks; use fullscreen=(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.
  3. Legacy Feature-Policy. The deprecated Feature-Policy header used a different grammar (geolocation 'self', no parentheses, quoted keywords). Modern browsers ignore it in favor of Permissions-Policy. Do not serve both with conflicting intent; remove Feature-Policy once Permissions-Policy is 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.

Permissions-Policy feature delegation map The top document's header gates each feature; an iframe receives a feature only when both the header allowlist and the frame's allow attribute grant it. Top document header camera=() geolocation=(self "https://maps.x") fullscreen=(self) gate iframe allow="geolocation" geolocation granted iframe allow="camera" camera DENIED (header ()) iframe (no allow attr) geolocation DENIED (no opt-in) A feature reaches a frame only when header allowlist AND allow= both grant it Empty allowlist () blocks every frame regardless of allow=
The header sets the outer gate; each iframe must additionally opt in via its allow attribute. Either gate closed means the feature is denied.

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.