faceicon

Let's take Google Meet as an example. When you try to join a very (cough cough) important (cough cough) work meeting, and the browser pops up a prompt requesting access to your camera and microphone... How do these browser permissions work? How many other permissions are out there?

asking

Browser Permissions

Permission is the key or gateway to accessing and requesting certain features. For example, if a webpage has 'camera' permission, it means the page can either request access to the camera or use it directly if previously granted. This type of permission, which requires the user to explicitly grant access through a popup, is categorized as 'powerful', which we will explain later.

Nowadays, many permissions exist—it’s not just the camera or microphone. There isn’t an official list of all the permissions supported by browsers or standarized, so I created what I believe is the most comprehensive permissions list (link). My list isn’t perfect, and not all the characteristics of the permissions may be accurate, but I believe it’s more useful than relying on the standard list or checking the Chromium code list. Alternatively, if you’re curious about which websites you have permissions granted on, you can check out: about://settings/content/siteDetails?site=https://albertofdr.github.io/ (links to about: doesn’t work for security reasons, so you need to copy&paste).

Example of Permissions
accelerometer camera clipboard-read clipboard-write
display-capture fullscreen geolocation gyroscope
idle-detection local-fonts microphone notifications

It’s important to note that some permissions are special. For example, the permission to download a file from a website differs from typical permissions like camera access. This occurs when the browser encounters a content type that is not the usual formats, such as HTML or standard resources. Currently, there are two key characteristics of permissions. The first is whether it’s a powerful permission, and the second is whether it’s policy-controlled. I’ve included examples of different permissions and their characteristics based on the Permissions Registry deprecated W3C Standard, but let’s now dive into both of these in more detail.

Permission Powerful? Policy-controlled? Default allowlist
camera self
geolocation self
gamepad *
notifications
push

Powerful Permission

A powerful permission simply means that the specific feature requires explicit consent from the user. If the permission has not been granted before, a prompt will appear, and the user will need to accept it. A common example of this is the camera and microphone popup that appears on meeting websites. In other examples, such as with serial or usb permissions, the user must select the device, which means these can also be categorized as powerful. An example of the javascript code to check the status (not for asking/prompting the user) of the permission would be:

1
2
3
4
5
6
async function checkPermission(permission){
let result = await navigator.permissions.query({'name': permission});
console.log(result['state'])
}

checkPermission('camera');

Policy-controlled Permission

Now, what does it mean for a permission to be policy-controlled? It means that the permission can either be delegated or not, and as a result, there is also a default allowlist in place. Let’s use the camera permission as an example. The camera permission is both powerful and policy-controlled. Its allowlist is set to self. Let’s break this down:

Because camera is a powerful permission, if a website wants to use it, it must prompt the user for consent, which the user needs to accept. On the other hand, the self allowlist means that only the top-level document (the main page) can access this permission. As a result, if there’s an iframe, such as one from meet.example.org, it will not be able to use the camera permission. In contrast, as it was previously some years ago, a default allowlist of * would allow the iframe to access the permission without an explicit delegation.

To check if a context, such as an iframe from meet.example.org, is able to access a permission, we can use: document.featurePolicy.allowedFeatures(). There are also other functions available, so open the browser console and see what the browser suggests. Additionally, in the future, these functions may be moved under document.permissionsPolicy.

That being said, you might wonder how an iframe could access the camera. The delegation of permissions fall into one of the following categories (chromium source code):

  1. Undelegated (e.g., notification and push).

Permissions from the main frame are not delegated to child frames. An undelegated permission will only be granted to a child frame if the child frame’s origin was previously granted access to the permission when in a main frame.

  1. Double-keyed (e.g., storage-access).

Permission access is a function of both the requesting and embedding origins.

  1. Delegated (e.g., camera and microphone).

Permissions from the main frame are delegated to child frames. This is the default delegation mode for permissions. If a main frame was granted a permission that is delegated, its child frames will inherit that permission if allowed by the permissions policy.

In this two last categories of delegation is where the allow attribute of the iframe comes into play. Continuing with the example the iframe would be created like:

<iframe src='//meet.example.org' allow='camera'></iframe>.

This may not be a good practice, so make sure to keep reading until the Good practices section.

Let’s take an example: You visit the website bubu.com, which requests access to your camera, and you allow it. Later, an iframe from meet.example.org is included with the allow='camera' attribute. At this point, would the iframe be allowed to access the camera without re-prompting? YES!.

Now that we’ve learned about the allow attribute and how permission delegation works, let’s look at the syntax. Without going into technical details just yet (we’ll cover that later in the blog), the value for a permission can be 'none', 'self', 'src', an origin (https://albertofdr.github.io/), or *. When the permission delegated doesn’t have an explicit directive (e.g., 'self'), the allowlist defaults to the iframe’s 'src' attribute. I’ll revisit this point when discussing the Permissions-Policy Standard later. You might be wondering how to delegate multiple permissions. This can be done by separating them with the ; character. A complete example would be:

allow="camera; geolocation 'self' https://tracker.com; display-capture 'none'".

To help you understand how the directives work, I’ve created the following table.

albertofdr.github.io albertofdr.github.io permission.site (Iframe)
Camera Access/Prompt Iframe Allow Delegation Camera Access/Prompt
no allow link
allow link
allow='' link
allow=camera link
allow="camera 'self'" link
allow='camera https://permission.site/' link
allow="camera 'https://permission.site/'" link
allow='camera *' link
allow="camera '*'" link

At this point, we’ve covered permissions and how they can be delegated to other contexts. Now, you might be wondering how a developer can completely restrict the use of a permission if it’s not needed. This is where the Permissions-Policy response header (formerly known as Feature-Policy) comes into play.

The Permissions-Policy header was formerly known as the Feature-Policy header. The main difference between them is the syntax, so it’s important to be cautious. Many websites still use the old Feature-Policy syntax in the Permissions-Policy header, which will not work.

pp-header

In simple terms, the Permissions-Policy header allows developers to opt-out of or restrict the use and delegation of permissions. At this moment, only supported by Chromium-based browsers and deployed in around 27% of the websites (chrome status). Continuing with the camera example, setting the camera permission to * does not override the default allowlist of 'self'. As a result, the permission will not be delegated unless the allow attribute is explicitly used. An example of a Permissions-Policy header would be:

Permissions-Policy: camera=(), microphone=()

This header would disallow the use of both the camera and microphone in ANY CONTEXT. Just like before, let’s take a look at this new table.

albertofdr.github.io albertofdr.github.io albertofdr.github.io permission.site (Iframe)
Permissions Policy Header Access/Prompt Iframe Allow Delegation Access/Prompt
No Header not delegate
No Header delegate
camera=() delegate
camera='self' delegate
camera=('self' permission.site) delegate
camera=* not delegate
camera=* delegate

Now that we understand how the Permissions-Policy header works for the top-level document, let’s explore how the Permissions-Policy header in iframes affects the usage.

WEBSITE TLD WEBSITE TLD WEBSITE TLD permission.site (Iframe) permission.site (Iframe)
Permissions Policy Header Access/Prompt Iframe Allow Delegation Permissions-Policy Header Access/Prompt
No Header not delegate No Header
No Header not delegate camera=*
No Header delegate No Header
No Header delegate camera=()
camera=() delegate No Header
camera=self delegate No Header
camera=* delegate No Header
camera=* delegate camera=()
camera=* delegate camera=self

As an exercise for the reader, I will add a few headers and allow attributes. The goal is to determine whether the permission can be used or is blocked in different contexts. Remember for the exercise that the default allowlist of camera is self.

Click to see a few simple scenarios to try out as exercises.
Exercise 1

Does the website have access to the camera?

Permissions-Policy: camera=()

No. The header blocks the use of the permission.
Exercise 2

Does the website have access to the camera?

Permissions-Policy: camera=(),

Yes. The trailing comma breaks the header, rendering it ineffective, as if it wasn’t defined.
Exercise 3

Does the website have access to the camera?

Permissions-Policy: camera='none'

Yes. The header uses 'none' with quotes, but directives should not be enclosed in quotes.
Exercise 4

Does the website have access to the camera?

Permissions-Policy: camera=none

No. The header correctly blocks access to the camera.
Exercise 5

Does the website have access to the camera?

Permissions-Policy: camera=(none)

No. The header prevents the camera from being used.
Exercise 6

Does the website have access to the camera? The website origin is https://meet.bubu.com/.

Permissions-Policy: camera=(https://meet.bubu.com/)

No. Since the URL is not enclosed in double quotes, the origin is not recognized as origin directive.
Exercise 7

Does the website have access to the camera? The website origin is https://bubu.com/.

Permissions-Policy: camera=('https://meet.bubu.com/')

Yes. Using single quotes breaks the parsing, invalidating the header entirely.
Exercise 8

Does the website have access to the camera? The website origin is https://bubu.com/.

Permissions-Policy: camera=("https://meet.bubu.com/")

No. The header is valid and restricts access to only the origin meet.bubu.com.
Exercise 9

Does the website have access to the camera? The website origin is https://bubu.com/.

Permissions-Policy: camera=(), microphone=(https://meet.bubu.com/)

No. While the microphone policy doesn’t apply correctly as before, it doesn’t affect the camera policy, which remains functional.
Exercise 10

Does the website have access to the camera? The website origin is https://bubu.com/.

Permissions-Policy: camera=(), microphone=('https://meet.bubu.com/')

Yes. Single quotes break the parsing, causing the browser to discard the header entirely.
Exercise 11

Does the website have access to the camera? The website origin is https://bubu.com/.

Permissions-Policy: camera=(), microphone=("https://meet.bubu.com/")

No. Both policies are correctly configured and work as expected.
Exercise 12

Does the iframe have permission to access the camera?

<iframe src='https://permission.site' allow="camera"></iframe>

Yes. With no header restrictions, and delegation defaulting to 'src', permission.site will have access to the camera if no redirects to different origin occur.
Exercise 13

Does the iframe have permission to access the camera?

Permissions-Policy: camera=(*)
<iframe src='https://permission.site'></iframe>

No. The header can only opt-out or restrict permissions; it cannot grant delegation on its own.
Exercise 14

Does the iframe have permission to access the camera?

Permissions-Policy: camera=self
<iframe src='https://permission.site' allow="camera"></iframe>

No. The header is properly configured to restrict access to the self context.
Exercise 15

Does the iframe have permission to access the camera? In this case storage-access default allowlist is *.

<iframe src='https://permission.site' allow="storage-access 'none';"></iframe>

No. Access is explicitly disabled in the delegation.
Extra

Does the website have access to the camera? The website origin is https://bubu.com/.

Permissions-Policy: camera=none

No. You might think that none gets interpreted and the policy is applied, but the truth is that none is invalid. However, since there are no other policies, it behaves like camera=().
Extra 2

Does the website have access to the camera? The website origin is https://bubu.com/.

Permissions-Policy: camera=(none *)

Yes. Again none is invalid and * is applied.
I bet you got at least one wrong!.
Close
At this point (06-02-2025), Self directive is mandatory if we want to delegate permission to other iframes (Github Spec Issue).

With this first section, we now have a solid overview of how permissions work in today’s web ecosystem. Let’s dive into the standards that govern and explain this.

Permissions W3C Standards

As of early 2025, two primary standards explain this: the Permissions Standard and the Permissions-Policy Standard. Additionally, individual standards must specify whether a permission is powerful or policy-controlled. There is also a Permission Registry standard, but it has been deprecated. Significant changes to both standards can be expected in the coming months and years. Furthermore, some corner cases and core aspects of these standards remain in discussion among major browser vendors and developers.

Permissions Standard

In simple terms, the Permissions Standard defines powerful permissions — or, as described in the specification:

This specification defines common infrastructure that other specifications can use to interact with browser permissions. These permissions represent a user’s choice to allow or deny access to “powerful features” of the platform. For developers, the specification standardizes an API to query the permission state of a powerful feature, and be notified if a permission to use a powerful feature changes state.

For a detailed explanation of what ‘powerful’ means, refer to this issue (link). Additionally, it’s worth noting that the standard defines the navigator.permissions.query function. This is the function used to check if a permission has been granted, can be prompt or is denied.

Permissions Policy Standard (formerly Feature Policy)

The Permissions-Policy standard is a bit more complex and can be somewhat confusing to understand. I’ll do my best to explain it clearly. This standard defines the Permissions-Policy header and the delegation using the allow attribute including the definition of default allowlist. Additionally, any individual standard that defines a policy-controlled feature with the corresponding default allowlist should reference this one.

This specification defines a mechanism that allows developers to selectively enable and disable use of various browser features and APIs.

Starting with the structured header: this means that a misplaced comma (camera=(),) or any unexpected element can break the browser’s serialization, causing it to remove the header. The consequence of this is that the website would function as if the header was never defined. For each permission, separated by a comma (camera=(), microphone=()), the header defines a set of directives, either individually (camera=self) or in groups (camera=(self "https://bubu.com/")), such as self, *, or a specific origin (standard section). For the allow attribute, permissions should be separated by a semicolon ; and, apart from self and *, you can also use none and src. Moreover, the standard also defines how the default allowlist works, specifying that the default allowlist for any permission can only be self or * (with none being considered as an option). This spec also defines how to check if a permission is allowed in your context. This is done using the document.permissionsPolicy.* (e.g., document.permissionsPolicy.allowedFeatures()) functions. As of January 2025, the functions are still part of the old Feature-Policy object, such as document.featurePolicy.allowedFeatures(). In this specification, the delegation using the allow tag is also included, and one important thing to note is the following:

The allowlist for the features named in the attribute may be empty; in that case, the default value for the allowlist is ‘src’, which represents the origin of the URL in the iframe’s src attribute.

Individual Permissions Standard (Ex: Media Capture and Streams)

Each individual specification related to a feature may refer to either the Permissions Standard or the Permissions-Policy Standard, depending on the feature’s characteristics. It typically also defines a permission associated with that feature. If the feature is policy-controlled, the specification should define the default allowlist (standard example). Sometimes, because the term ‘powerful’ is relatively new, some standards may instead say something like ‘the user needs to grant the permission’.

If you want to learn more, give the spec a try. While you might not fully understand how the standards are written at first, it’s worth exploring.

Permission Common Misconceptions

1. Permissions-Policy only defines the Permissions-Policy Header

Some people may think that the Permissions-Policy standard only defines the header. This is completely incorrect. The standard actually defines the entire policy and delegation ecosystem, including delegation, as we’ve seen. In my opinion, this is a great starting point and should be pushed forward.

2. All Defined Permissions are Powerful and Policy controlled

As the Permissions standard and Permission Registry standard states:

Not every policy-controlled feature is powerful features. For example, “web-share” is a policy-controlled feature that is not classified as a powerful feature because it doesn’t require express permission to be used. However, with very few exceptions, most powerful features are also policy-controlled features. For example, “geolocation” is both a policy-controlled feature and a powerful feature, as it requires express permission to be used. Please refer to the Permissions specification for guidance on how to specify a powerful feature.

One of the things that browser vendors and those working on the standard should do is define a comprehensive list of permissions. This way, developers wouldn’t need to rely on my list and would have a clear understanding of the characteristics of each permission. It would also allow for defining permissions in the header and disabling those that developers know are not being used.

3. Policy Declaration when iframe A includes iframe B

iframe-inside-iframe

One of the biggest sources of confusion for website developers is what happens to the policy declared by the header when an iframe contains another iframe. Developers often believe they can define a policy that affects nested iframes. However, this would violate one of the core principles of the web: the Same-Origin Policy (SOP). In other words, once a permission is delegated to a different context, that context can do whatever it wants with the permission. So, in the example, if the permission is delegated to Iframe A, Iframe A could then delegate it to Iframe B. There is no header or mechanism that a website developer can use to prevent this second delegation. This is one of the reasons developers should be cautious about which contexts or iframes they delegate permissions to.

4. Misleading prompt text when Permission requested from an iframe

prompt-from-iframe

Developers may assume that when an iframe, like meet.example.org, is included in albertofdr.github.io and attempts to access a permission, the prompt would say something like: ‘meet.example.org wants to access the permission’. However, this is not the case. The prompt will actually say: ‘albertofdr.github.io wants to access the permission.’

5. What happens when a new iframe want the already granted permission

A typical developer might assume that when a new iframe is created, the previously granted permissions would not be accessible. However, this is not the case. Once a permission has been granted—whether now or 30 days ago (depends on the browser)—any iframe (with the appropriate delegation, if needed) will have access to that permission.

6. Dynamically Delegating Permissions

Some people might think that changing the allow attribute of an iframe would immediately affect the delegated permissions, but this is not true. Delegation cannot be set dynamically. In other words, once the document context of the iframe is created, changing the delegation requires two steps: first, update the allow attribute, and second, reload the iframe’s context (e.g., window.location.reload()). Now, I’ll present different cases to clarify this. Let’s take an example of a website including an iframe.

declare allow allow="camera *"
declare src src="abc.org"
abc.org camera allowed ✅
abc.org –> REDIRECT –> xyz.org
xyz.org camera allowed ✅
declare allow allow="camera"
declare src src="abc.org"
abc.org camera allowed ✅
abc.org –> REDIRECT –> xyz.org
abc.org camera denied ❌
declare src src="abc.org"
abc.org camera denied ❌
declare allow allow="camera *"
abc.org camera denied ❌
declare src src="abc.org"
abc.org camera denied ❌
declare allow allow="camera *"
abc.org camera denied ❌
abc.org –> REDIRECT –> xyz.org
xyz.org camera allowed ✅
declare src src="abc.org"
abc.org camera denied ❌
declare allow allow="camera *"
abc.org camera denied ❌
abc.org uses window.location.reload()
abc.org camera allowed ✅
declare src src="abc.org"
abc.org camera denied ❌
declare allow allow="camera *"
declare src src="abc.org#bubu"
abc.org camera denied ❌
Explanation: Fragment is not reloading the document context.
declare src src="abc.org"
abc.org camera denied ❌
declare allow allow="camera *"
declare src src="abc.org"
abc.org camera allowed ✅
Explanation: Setting src produces a reload.
declare src src="abc.org"
abc.org camera denied ❌
declare allow allow="camera *"
declare src src="abc.org/bubu/profile"
abc.org camera allowed ✅
Explanation: Setting path produces a new context.
declare src src="abc.org"
abc.org camera denied ❌
declare allow allow="camera *"
declare src src="abc.org/?user=13"
abc.org camera allowed ✅

7. Permissions-Policy Header vs Feature Policy Header

The main difference, aside from the name, lies in the syntax used in the header. Furthermore, in Permissions-Policy header none token is invalid. Take a look at these two examples:

Feature-Policy: camera ‘self’; geolocation ‘none’

Permissions-Policy: camera=’self’, geolocation=()

If you’re curious, when both headers are declared for the same permission, the Permissions-Policy directive takes precedence. However, if a permission directive is only present in the Feature-Policy header, it will still work and apply the policy. Remember that still only applies to Chromium-based browsers.

HTML <permission> tag (PEPC)

newasking

In mid-2024, Google folks introduced the <permission> HTML tag (blog). The two major changes in their proposal are, first, a visual change that blurs the entire screen instead of using a traditional prompt, and second, the introduction of a button-like element to request permissions

<permission type="camera microphone"></permission>.

If you’re interested, feel free to check out their demo or the explainer.

Good practices

In the following lines, I’ll share a few pieces of advice, but keep in mind that these may not apply to every case and are not foolproof. Starting with the Permissions-Policy header, since there are currently no permission groups and each permission must be disabled manually, a good starting point would be the following setup. The goal is to disable all types of powerful browser features that are not commonly used and are only required by specific categories of websites. However, I recommend reviewing my list and keeping in mind that this may change in the future as new permissions are introduced.

camera=(), captured-surface-control=(), clipboard-read=(), display-capture=(), geolocation=(), idle-detection=(), local-fonts=(), microphone=(), midi=(), nfc=(), screen-wake-lock=(), system-wake-lock=(), usb=(), window-management=(), xr-spatial-tracking=()

If it’s not strictly necessary, I always recommend avoiding changes to the header or adding third-party widgets, as you are ultimately responsible for what third parties do with your users’ data. Another important point to address is permission delegation. Similar to header directives, both website developers and third-party widget developers should avoid delegating permissions. Doing so increases the attack surface and the risk of vulnerabilities, such as permission hijacking.

Let’s consider a real-world example: a support chat widget that facilitates communication between clients and company representatives to address queries. Each company using such a widget would include it with the same origin, modifying, for instance, a parameter like id to identify the specific company. Based on this id and the plugins installed in the chat widget, only the necessary permission delegations should be used. Third-party companies should avoid using a generic template that delegates all potentially required permissions. (If you’re curious about these kinds of cases and want to learn more, you will have to wait and see if my academic paper gets accepted soon 💀).

Thoughts on Security Risks

At this point, I’d like to mention two types of attacks that came to mind after watching a Google presentation by the great researcher jkokatsu (shhnjk) or this academic paper (Studying the Privacy Issues of the Incorrect Use of the Feature Policy) by Beliz Kaleli. The first case involves a website that uses powerful permissions, such as access to the camera and microphone. Let’s take meet.bubu.com as an example.

htmli

Imagine that the website has a robust Content-Security-Policy but lacks a Permissions-Policy. In this case, the bug discovered and explained in the following section to bypass self directives not only applies but could make the situation even worse. The CSP header, which lacks directives for including other documents (e.g., frame-src), effectively prevents any XSS scripting attacks or CSS exfiltration. At this point, client-side attacks would be significantly reduced. Now, let’s say you find an HTML injection on the page, whether stored or reflected. On its own, this would be a vulnerability without much impact due to existing mitigations. However, the point I want to emphasize here is how HTML injection could be escalated into a permission hijacking attack. Why? Because the permissions have already been granted, and you could potentially delegate access to the camera, microphone, and display-capture to an attacker’s page. This attack would allow the attacker to make use of features from their page. Furthermore, while videoconferencing websites often provide an option to turn off the camera or microphone, this does not revoke the permission. As a result, the attacker could still listen in on or view people who are theoretically muted. Even in cases where the permission has not been granted before, you can attempt to trick the user due to the misleading prompt explained earlier.

chain

The second type of attack I have in mind involves widgets with delegated permissions. Let’s imagine a very common widget from bubuchat.com that is embedded on thousands of pages, used by thousands of users every day. This widget, for certain functionalities, has delegated access to important permissions, such as display-capture. If an attacker is able to exploit this company and alter the widget, they would gain the ability to modify the code that has access to the permissions. In cases where the permission has not been granted yet, the attacker would only need to request the permission, because, as explained earlier, the prompt would display the website’s name, not the widget’s.

Headerless Document Strikes Again

As a final point, I’d like to mention a bypass I discovered due to a specification issue, which affects all Chromium-based browsers. Some of this, along with many other findings, may be published, if I’m lucky, in an academic paper.

In simple terms, there is a way to delegate a permission to a third party, even when the Permissions-Policy header restricts that permission to self (e.g., camera=self). You might be wondering, how is this possible?

meet.com headerless document (data: URI) attacker.com
Permissions-Policy Header Camera Access/Prompt Allow Delegation Camera Access/Prompt
Expected camera=self delegate (allow=camera)
Real camera=self delegate (allow=camera) ✅🐛

As shown in the table and title, the key lies in using a headerless document. These documents, which lack response headers (hence the term ‘headerless’), inherit headers in cases like Content-Security-Policy to prevent bypasses (CSP Standard). Currently, however, this does not happen with Permissions-Policy, allowing us to use this technique to bypass the ‘self’ restriction. This issue has been reported in the standard but remains unresolved, as you can see here:

Last thoughts

As a simple conclusion, I’d like to write a few lines about the complexity of these mechanisms for developers. For example, implementing the Permissions Policy header can be error-prone—something as small as a misplaced comma can invalidate the entire header. This approach simplifies parsing on the browser side, and I’m not saying it’s a bad thing, but developers should have tools—like my own for generating the Permissions-Policy header—to help them effectively use these important security mechanisms.

Thanks for reading

Part of this research was conducted in collaboration with the excellent researcher, Jannis.
If you have any recommendation/mistake/feedback, feel free to reach me twitter :)

References: