# Permissions-Policy Header

> Learn how the Permissions-Policy header controls which browser features and APIs can be used in your site and embedded iframes. Enhance security and privacy.

Source: https://howhttpworks.com/headers/permissions-policy
Last reviewed: 2026-10-05

> **TL;DR:** `Permissions-Policy` decides which origins may use browser features like the camera, microphone and geolocation in your page and its iframes. `Permissions-Policy: camera=(), microphone=(), geolocation=()` switches all three off in supporting browsers. Allowing a feature only makes it possible: the user still has to grant permission, and a cross-origin iframe also needs a matching `allow` attribute.

## What is Permissions-Policy?

This response header sets which origins may use specific policy-controlled browser features. It applies to documents and the frames nested inside them, not to individual scripts. A third-party script running in your page is bound by your page's policy, whatever origin it was downloaded from. [MDN explains how inheritance works](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Permissions_Policy).

## How Permissions-Policy Works

An empty allowlist turns a feature off. `self` allows your own origin, as long as the feature's other requirements, such as HTTPS and user permission, are met. Once an ancestor denies a feature, no iframe below it can turn it back on: `camera=()` on the parent beats `allow="camera"` on a child.

## Syntax

A typical header (examples on this page are trimmed):

```http
Permissions-Policy: camera=(), microphone=(), geolocation=(self "https://maps.example.com")
```

Directives are separated by commas. Inside the parentheses, list `self` and double-quoted origins separated by spaces. `camera=*` is the wildcard and `camera=()` is the empty list. This header uses its own syntax, so Feature-Policy's semicolon-separated format doesn't carry over.

## Common Features

Camera, microphone and geolocation default to `self`, not `*`. Every feature has its own default, so leaving a feature out of your header doesn't mean it's allowed everywhere. Check the individual [camera](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy/camera), [microphone](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy/microphone) and [geolocation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy/geolocation) references.

For a video embed that also needs fullscreen, these are the defaults that matter:

| Directive | Default allowlist | API restriction |
| --- | --- | --- |
| `camera` | `self` | Camera access through getUserMedia |
| `microphone` | `self` | Microphone access through getUserMedia |
| `geolocation` | `self` | Geolocation API |
| `fullscreen` | `self` | Fullscreen API |

The [fullscreen directive](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy/fullscreen) has its own compatibility table. Think of an allowlist as a ceiling. It sets the most a frame can do; the user's permission decides the rest.

## Real-World Scenarios

For a map iframe, serve the parent page with the geolocation policy above and delegate the feature to the frame:

```html
<iframe src="https://maps.example.com/store-locator" allow="geolocation"></iframe>
```

The frame can now ask the user for their location; the user still decides. MDN's [geolocation example](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy/geolocation#examples) shows why you need both the parent header and the iframe delegation.

For a cross-origin video-call frame, name that origin in the parent's policy:

```http
Permissions-Policy: camera=(self "https://call.example.com"), microphone=(self "https://call.example.com"), fullscreen=(self "https://call.example.com")
```

Then delegate the same features on the frame:

```html
<iframe src="https://call.example.com/room"
  allow="camera; microphone; fullscreen" title="Video call"></iframe>
```

The iframe's [allow attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLIFrameElement/allow) can only narrow what the parent policy allows. If the frame later navigates to a different origin, revisit the allowlist; the original delegation may not apply to the new origin.

## Server Configuration

In the nginx server or location that serves your HTML:

```nginx
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
```

In Apache with `mod_headers` loaded:

```apache
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
```

See the [nginx](https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header) and [Apache](https://httpd.apache.org/docs/2.4/mod/mod_headers.html#header) directive docs. Pick one layer to own the policy, and check the header that actually arrives after every proxy.

In Express, you can turn device APIs off by default and enable them only on the page that needs them:

```javascript
app.use((_req, res, next) => {
  res.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
  next()
})
app.get('/call', (_req, res) => {
  res.set('Permissions-Policy', 'camera=(self), microphone=(self), geolocation=()')
  res.sendFile('call.html', { root: '/srv/app/public' })
})
```

Put the HTML file at that fixed path. Register the default middleware before the route, so the route's policy is the one that lands; a global setter that runs later would overwrite it.

In Flask, an after-request hook with `setdefault` adds the default without clobbering a route's own policy:

```python
from flask import Flask, make_response, render_template

app = Flask(__name__)

@app.after_request
def policy(response):
    response.headers.setdefault(
        "Permissions-Policy", "camera=(), microphone=(), geolocation=()"
    )
    return response

@app.get("/call")
def call():
    response = make_response(render_template("call.html"))
    response.headers["Permissions-Policy"] = "camera=(self), microphone=(self)"
    return response
```

`call.html` goes in Flask's templates directory. [Flask's after_request hook](https://flask.palletsprojects.com/en/stable/api/#flask.Flask.after_request) receives the response and has to return it. The call page gets its own policy while every other page keeps the camera off.

## HTML Equivalent

The iframe `allow` attribute uses a different syntax and only affects that one iframe, so it can't stand in for the document-wide header. To turn off camera and microphone in a single frame:

```html
<iframe src="https://embed.example.com/widget" allow="camera 'none'; microphone 'none'"></iframe>
```

## Migration from Feature-Policy

An old `Feature-Policy: geolocation 'none'` becomes `Permissions-Policy: geolocation=()`. Check each feature name as you migrate, because which directives are supported varies by browser and feature.

Here's a full conversion:

```http
Feature-Policy: camera 'none'; microphone 'self'
```

```http
Permissions-Policy: camera=(), microphone=(self)
```

The iframe attribute didn't change: it still uses semicolons and quoted keywords. Leave its value alone; pasting the comma-separated header syntax into it breaks it.

## Getting Permissions-Policy right

Put the policy on the document that contains the iframe. A policy on the JavaScript file or a JSON API response has no effect on the page. Keep the parent's allowlist and the iframe's `allow` attribute in agreement.

## Common Patterns

A video-call page might use `camera=(self), microphone=(self)`, while a static content page turns both off. Base the policy on what each page actually uses, instead of pasting a long list of experimental directives.

If the call runs on your own origin, `camera=(self)` also covers same-origin frames inside it, within whatever their ancestors allow. For a third-party call frame, use the explicit origin and delegation shown above. Setting `camera=()` site-wide and then trying to allow the camera only in the iframe fails, because the parent has already said no.

## Testing Permissions-Policy

With `camera=()` in place, try this from a secure page:

```javascript
try {
  const stream = await navigator.mediaDevices.getUserMedia({ video: true })
  stream.getTracks().forEach((track) => track.stop())
} catch (error) {
  console.log(error.name)
}
```

Supporting browsers report a policy denial as `NotAllowedError`. A user clicking "Block" produces the same name, so look at the response header and the iframe's policy before you blame either one. [MDN lists getUserMedia's error cases](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#exceptions).

Check browser support directive by directive; there's no single version where the whole header starts working. MDN marks the [header as limited availability](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy). Seeing the header in the Network panel only tells you it was sent, so test each directive you depend on.

## Common Issues

Allowlists take origins, not URLs with paths. Write `"https://maps.example.com"`, not the store-locator URL. A child frame can't loosen a parent's denial, and a parent's permission doesn't skip the user's.

A sandboxed frame without `allow-same-origin` has an opaque origin and can't use getUserMedia, and `allow="camera"` won't change that. [MDN lists the iframe and secure-context requirements](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#security). Check the sandbox attributes as well as the policy before editing the parent's allowlist.

## Security Considerations

Permissions Policy restricts a specific set of APIs. It doesn't stop tracking in general or control everything an embedded script does, and it's no substitute for CSP or iframe sandboxing. Turning off the camera and geolocation has no bearing on which scripts can load or where they can connect; that's CSP's job.

## Related Headers

- [Content-Security-Policy](https://howhttpworks.com/headers/content-security-policy)
- [Cross-Origin-Embedder-Policy](https://howhttpworks.com/headers/cross-origin-embedder-policy)
- [X-Frame-Options](https://howhttpworks.com/headers/x-frame-options)
