skip to content

A page embeds a cross-origin map widget with <iframe src="https://maps.example/w" allow="geolocation">, and geolocation inside the frame still fails immediately without ever prompting the user. The same widget works when opened as a top-level page. What does Permissions-Policy have to do with it?

level: seniorimportance: should knowfreq 33%

answer

  1. two gates, not one
  2. no prompt means policy, not user
  3. intersection down the frame chain
  4. default allowlist is self
  5. header on the embedding document

basics

~20 s

Delegation is an intersection: a frame gets a feature only if the embedding document is itself permitted to use it and passes it down. If the top document's Permissions-Policy response header omits the widget's origin, the frame is disabled no matter what the container markup says.

solid answer

~50 s

Permissions Policy computes each document's permitted features as an **intersection**, not a union. The frame's container markup can only pass along what the embedder itself holds. So if the top-level document sends a `Permissions-Policy` response header whose geolocation allowlist is narrower than the widget's origin — for example `geolocation=(self)`, or `geolocation=()` which disables it everywhere — the frame ends up with the feature disabled, and the API fails with no prompt at all, because there is nothing to ask about. Widen the header to include the embedded origin: `Permissions-Policy: geolocation=(self "https://maps.example")`. Two related traps: a document that sends no header keeps the feature's default allowlist, which for powerful features is `self`, so cross-origin frames are off by default; and even a fully delegated feature still needs the user's permission on top — delegation controls whether asking is possible, never whether the answer is yes.

code

html · 9 lines
html
<!-- The embedding document must also send:
     Permissions-Policy: geolocation=(self "https://maps.example") -->
<iframe
  src="https://maps.example/w"
  allow="geolocation"
  title="Store locator"
  width="600"
  height="400"
></iframe>

go deeper

for a junior

Know that a cross-origin iframe does not automatically get powerful features like geolocation or camera, and that the embedding page has to opt in. Recognise 'fails with no prompt' as a different symptom from 'user said no'.

for a middle

Explain the Permissions-Policy response header and its allowlist syntax, and that features such as geolocation default to an allowlist of self, which is why embedding breaks them without any explicit denial.

for a senior

Diagnose across the whole frame chain: reproduce the widget top-level, read the embedder's headers, account for intersection at every hop, and separate a policy block from a stored denial before proposing a fix.

for a principal

Own the policy as a platform decision: which third-party origins may ever receive delegated capabilities, given that the user's grant is recorded against your top-level site, and what the default-deny header baseline is for pages that need nothing.

## Two independent gates A powerful feature inside a frame has to clear two entirely separate checks, and confusing them wastes hours of debugging. 1. **Permissions Policy** — is this document *allowed to ask*? Decided by the embedder chain and HTTP response headers, with no user involvement. 2. **The user's permission** — has the user *said yes*? Decided by the browser's prompt and stored per origin. The tell-tale symptom of a policy failure is that nothing is displayed to the user. The geolocation error callback fires immediately, or a promise rejects with `NotAllowedError`, and no prompt ever appears. When the user is genuinely being asked and refusing, you see the prompt. ## The intersection rule Every document has an allowlist per feature, and a nested document's allowlist is the intersection of what its parent holds and what its parent chooses to hand down. Two consequences follow. First, a frame can never gain a feature its embedder lacks. If the top-level document has disabled geolocation for itself, no container markup, however permissive, restores it for a child. Second, the top-level document's own allowlist comes from its `Permissions-Policy` response header, or from the feature's **default allowlist** if no header is sent. For the powerful features — geolocation, camera, microphone, display capture — the default is `self`, meaning "the top-level origin and same-origin descendants". That is why a cross-origin widget is off by default and why the failure appears only after embedding. ## The header `Permissions-Policy` is an HTTP response header using structured-fields syntax: a comma-separated list of `feature=allowlist` entries, where the allowlist is a parenthesised list containing the bare token `self`, quoted origins, or the wildcard `*`; an empty list `()` disables the feature entirely. ```http Permissions-Policy: geolocation=(self "https://maps.example"), camera=(), fullscreen=* ``` That example permits geolocation for the document's own origin plus the map widget's origin, disables the camera outright for the page and everything it embeds, and allows fullscreen anywhere in the tree. Note that a mistyped or unknown feature name is ignored rather than reported, so a policy that "has no effect" is often a spelling problem. `Feature-Policy` is the deprecated predecessor of this header; new deployments should send `Permissions-Policy`, and browsers that still honour the old name treat it as a fallback. ## Debugging the failure The efficient order is: 1. Load the widget as a top-level page. If it works there, the widget is fine and the embedding chain is the problem — which is exactly the observation in this question. 2. Look at the top-level document's response headers for `Permissions-Policy`. A missing header is not neutral; it means the default allowlist applies. 3. Walk the whole ancestor chain, not just the immediate parent. In a nested arrangement, an intermediate frame that does not pass the feature down truncates it for everything beneath. 4. Confirm the frame is in a secure context, since powerful features require one regardless of policy. Chromium's DevTools surfaces the computed policy for a selected frame, which short-circuits most of this. ## Why the design is shaped this way Without it, embedding any third-party content would silently expose every capability the embedding origin holds. A user who granted your site their location would be granting it to every advertisement, analytics frame and social widget you host, with the prompt attributed to your origin — because permissions are keyed on the top-level site, not on whoever is technically making the call. Requiring an explicit, per-origin delegation makes exposure a deliberate act by the embedder. It also gives a site a way to bind its own hands. Sending `Permissions-Policy: camera=(), microphone=(), geolocation=()` on a page that legitimately needs none of them means a script injected into that page cannot use them either. That is a real hardening measure and one of the more underused headers on the platform. ## The half that is still the user's Delegation only opens the door. When the widget calls the geolocation API in a properly delegated frame and the user has not decided, the browser prompts — and the prompt names the **top-level site**, not the embedded origin, because that is the site the user believes they are dealing with. A stored grant therefore attaches to the top-level site, so a user who already allowed your site is not prompted a second time when the widget asks. That is a strong reason to be conservative about which origins you delegate to: from the user's point of view, they said yes to you.

  • How do you tell a Permissions Policy block from a user denial without reading any headers?
    Watch for the prompt. A policy block fails instantly with no user interface at all, because the browser has nothing to ask about. A denial means the user saw the prompt at some point and refused, so the stored state reads 'denied' and re-triggering it still produces no prompt but a different diagnosis — check `navigator.permissions.query()` to separate them.
  • What does sending Permissions-Policy: camera=(), geolocation=() buy a page that uses neither feature?
    It removes the capability from the document and everything it embeds, so injected or compromised script on that page cannot reach the camera or location even if the user would have granted it. It is defence in depth that costs nothing on a page with no legitimate use, and it is one of the cheapest hardening headers available.
  • In a three-level nesting, the top document delegates geolocation to the middle frame but the innermost frame still cannot use it. Why?
    Because the allowlist is intersected at every level. The middle frame received the feature but must itself pass it down to its own child; if its container markup for the inner frame does not include the feature, delegation stops there. Every hop in the chain has to opt in explicitly.

saying these in an interview costs you the question

  • Thinks the embedded origin's own headers control the frame's features
  • Assumes sending no policy header means everything is allowed
  • Confuses this with the sandbox attribute's security tokens
  • Believes delegation grants the feature without asking the user
  • Checks only the immediate parent in a nested frame chain

context