Referrer-Policy and Permissions-Policy

This reference is part of the Web Security Headers Fundamentals reference and covers two privacy-and-capability headers that ship together in almost every hardened deployment: Referrer-Policy, which governs what the browser puts in the outgoing Referer request header, and Permissions-Policy, which governs which powerful browser APIs a document and its embedded frames may use. They solve different problems — one stops URL metadata from leaking, the other stops hardware and capability abuse — but both are declarative allowlists evaluated by the browser before any page script runs, and both are commonly set side by side at the same edge or origin layer.

The two headers are independent. Referrer-Policy controls an outbound request header; Permissions-Policy controls a per-document capability surface. Neither is a substitute for Content Security Policy, which restricts what resources load, and neither enforces transport security the way HTTP Strict Transport Security does. Treat all four as separate layers.

Threat Model & Protocol Mechanics

Referrer leakage

When a browser navigates or fetches a subresource, it attaches a Referer header (the historical misspelling is baked into the protocol) describing the URL the request originated from. Without a policy, older browser defaults transmitted the full URL — scheme, host, path, and query string — to any same-protocol destination. That is a direct data-exfiltration channel:

The Referrer Policy specification (W3C) defines the eight policy tokens and the exact stripping behaviour for each, including how the policy interacts with protocol downgrade (HTTPS → HTTP). Modern Chromium, Firefox, and Safari default to strict-origin-when-cross-origin, but the default is not guaranteed across privacy-hardened or legacy agents, so the header must be set explicitly for deterministic behaviour.

The stripping is not applied to the stored URL; it happens at the moment the browser composes the outbound request. Under the recommended baseline, a request that leaves your origin for a third party is rewritten so that only the origin survives — the sensitive path and query never leave the machine.

Several behaviours surprise engineers who read only the token names. The “origin” a policy emits is the serialised tuple of scheme, host, and port with a trailing slash (https://app.example.com/), never a bare host; a non-default port such as :8443 is part of that origin and is preserved. A change of scheme from https to http is a downgrade and every strict- token suppresses the Referer entirely on it, but a change from http to https is an upgrade and is not treated as a downgrade, so the header is sent. The policy governs subresource fetches, <img>, <script>, fetch(), form submissions, and <link rel="prefetch"> exactly as it governs top-level navigations — an <img src> pointing at a third-party tracker leaks the same path a link would. A per-request override still exists: an individual element may tighten (never loosen relative to what the document allows in a way that leaks more) its own policy with a referrerpolicy attribute (<a referrerpolicy="no-referrer">), and a rel="noreferrer" link drops the header for that navigation regardless of the document policy. Redirects re-evaluate the policy at each hop, so a same-origin URL that 302-redirects to a third party still has its Referer trimmed to the origin on the cross-origin leg.

How the Referer header is trimmed on a cross-origin navigation A left-to-right flow showing a full origin URL passing through the strict-origin-when-cross-origin policy and reaching a third party as an origin only, with path and query removed. Cross-origin request under strict-origin-when-cross-origin Outgoing request URL https://app.example.com /account/reset ?token=abc123 Referrer-Policy strict-origin-when -cross-origin keep origin only, drop path + query Third party receives Referer: https://app.example.com/ path & query gone Same-origin requests still send the full URL; an HTTPS→HTTP downgrade sends no Referer at all.
The browser rewrites the outbound Referer at request time, so a reset token in the path or query never reaches the cross-origin destination.

Hardware-feature and capability abuse

Permissions-Policy exists because a document — including a third-party <iframe> you embed — can otherwise attempt to use powerful APIs: geolocation, camera, microphone, payment, usb, fullscreen, autoplay, and motion sensors. The threats:

Permissions-Policy denies these by default per feature and delegates them explicitly. It supersedes the deprecated Feature-Policy header. The Permissions Policy specification (W3C) defines the header as an HTTP structured field — a structured dictionary whose values are allowlists of origins. Getting the structured-field syntax wrong (legacy Feature-Policy whitespace-separated grammar, missing quotes, trailing commas) makes the browser silently ignore the directive, which fails open.

Referrer-Policy value to leaked-data matrix A matrix showing which part of the URL each Referrer-Policy value sends for same-origin, cross-origin HTTPS, and HTTPS to HTTP downgrade navigations. Policy value same-origin cross-origin HTTPS→HTTP no-referrer none none none origin origin origin origin strict-origin-when -cross-origin full URL origin none origin-when -cross-origin full URL origin origin no-referrer-when -downgrade full URL full URL none unsafe-url full URL full URL full URL
What each Referrer-Policy value sends. The boxed row is the modern browser default and the recommended baseline.

Directive Syntax & Spec

Referrer-Policy

The header value is a single policy token (or a comma-separated list where later tokens are fallbacks for unsupported earlier ones):

Referrer-Policy: strict-origin-when-cross-origin
Directive Type Default Security Impact When to Deviate
no-referrer token Sends nothing, ever. Maximum privacy. Use for high-sensitivity flows; breaks affiliate/attribution.
no-referrer-when-downgrade token legacy default Sends full URL except on HTTPS→HTTP. Leaks full path cross-origin. Documented legacy fallback only.
origin token Always sends only scheme://host:port. When any cross-origin attribution by origin is acceptable.
origin-when-cross-origin token Full URL same-origin, origin cross-origin, but still sends on downgrade. Rarely; the strict variant is safer.
same-origin token Full URL same-origin, nothing cross-origin. Strict same-origin-only apps.
strict-origin token Origin only, and nothing on downgrade. When you never need same-origin paths in Referer.
strict-origin-when-cross-origin token modern default Full URL same-origin, origin cross-origin, nothing on downgrade. The recommended baseline.
unsafe-url token Always sends the full URL, including on downgrade. Leaks everything. Never in production.

Common gotcha: an unrecognised token causes the browser to fall back to its default policy, not to fail. Always validate the exact spelling. A <meta name="referrer"> tag overrides nothing if the HTTP header is present and parsed — but a malformed header lets the meta tag win.

For the recommended baseline, see setting Referrer-Policy: strict-origin-when-cross-origin, which covers the per-stack rollout for that one value in depth.

Permissions-Policy

The value is a structured-field dictionary. Each entry is feature=<allowlist>, where the allowlist is one of () (deny all), * (allow all origins), (self) (this origin only), or (self "https://trusted.example") (this origin plus named origins, each origin a quoted string). Multiple features are comma-separated:

Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=(self), fullscreen=(self)
Directive Type Default behaviour Security Impact When to Deviate
geolocation=() allowlist deny all Blocks location access in document and all frames. (self) if your own app needs location.
camera=() allowlist deny all Blocks getUserMedia video. (self) for video-call features.
microphone=() allowlist deny all Blocks getUserMedia audio. (self) for voice features.
payment=() allowlist deny all Blocks the Payment Request API. (self) or (self "https://checkout.example") for checkout.
fullscreen=(self) allowlist varies by feature Restricts requestFullscreen to your origin. * only if embedders legitimately need it.
usb=() / serial=() allowlist deny all Blocks WebUSB/WebSerial device access. Almost never deviate.

Delegation gotcha: omitting a feature from the header does not deny it — each feature has its own spec default (some default-allow for the top document). To guarantee denial you must list the feature with (). The allowlist values are quoted origin strings; bare hostnames or the legacy whitespace-separated Feature-Policy grammar are silently rejected.

Delegation to a child frame is a second, independent gate. Even when a feature appears in the header allowlist, a cross-origin <iframe> cannot use it unless the frame element also carries a matching allow attribute — the two must agree. The evaluation order below shows why a feature can be silently permitted (the omitted-feature case) or silently blocked (the missing allow attribute case).

Two structured-field details trip up hand-written policies. The wildcard is written bare as feature=* (allow every origin) and is not quoted, whereas every concrete origin is quoted — payment=(self "https://checkout.example") — and self is the one keyword that appears unquoted inside the parentheses. A single origin still needs the parentheses: geolocation=("https://maps.example") is valid, but geolocation="https://maps.example" without them is a parse error that discards the whole entry. Because the value is one structured-field dictionary, a syntax error in a single feature does not fail just that feature — a browser that cannot parse the field abandons the entire header, so one stray unquoted origin can silently drop every deny you wrote. Feature names are also not interchangeable with the old Feature-Policy vocabulary: the modern header uses hyphenated identifiers such as clipboard-read, clipboard-write, display-capture, screen-wake-lock, and browsing-topics, and an unknown feature name is ignored rather than rejected, so a typo like microphon=() leaves the microphone at its permissive spec default. Reporting is available: pair Permissions-Policy with a Reporting-Endpoints header and add a report-to group so blocked-feature violations are delivered to your collector, which surfaces frames attempting denied capabilities without waiting for a user complaint. The unload feature is a special case — setting unload=() opts a document out of unload event handlers, which is a prerequisite for back/forward-cache eligibility in Chromium rather than a security control.

Permissions-Policy evaluation decision tree A top-down decision tree that resolves a browser feature request to allowed or denied based on header presence, the origin allowlist, and iframe delegation. Feature key present in Permissions-Policy header? Requesting origin in the allowlist? (not empty) Inside a cross-origin iframe? Browser spec default applies (some features allow!) Denied — empty or unlisted PERMISSION_DENIED Allowed in the top document Allowed only if the iframe allow= attribute also grants it Yes No Yes No No Yes
Three independent gates decide the outcome. A feature omitted from the header falls through to its spec default (top-right), which is why explicit () entries are mandatory.

Platform-Specific Implementation

Nginx

add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(self), fullscreen=(self)" always;

Security impact: always emits the header on error responses (4xx/5xx) too, closing the gap where an error page would otherwise ship without policy. Note that any add_header inside a location block replaces all inherited add_header directives — repeat both headers in every block that defines its own.

Verify: curl -sI https://example.com | grep -iE 'referrer-policy|permissions-policy'

Apache

Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(self), fullscreen=(self)"

Security impact: always adds the header to internally generated error responses as well as 2xx; set (vs add) guarantees a single deterministic value and overrides upstream module output. Place in <VirtualHost> or .htaccess with mod_headers enabled.

Verify: curl -sI https://example.com | grep -i permissions-policy

IIS

<system.webServer>
  <httpProtocol>
    <customHeaders>
      <add name="Referrer-Policy" value="strict-origin-when-cross-origin" />
      <add name="Permissions-Policy" value="geolocation=(), camera=(), microphone=(), payment=(self), fullscreen=(self)" />
    </customHeaders>
  </httpProtocol>
</system.webServer>

Security impact: customHeaders in web.config applies the header to every response the site returns, including static files served directly by IIS without hitting application code. Use <remove> before <add> if a parent config already declares the header, to avoid duplicates.

Verify: curl -sI https://example.com | findstr /I permissions-policy

Node/Express (Helmet)

Helmet sets Referrer-Policy natively. Permissions-Policy has no built-in Helmet helper, so set it raw:

const helmet = require("helmet");

app.use(
  helmet({
    referrerPolicy: { policy: "strict-origin-when-cross-origin" },
  })
);

app.use((_req, res, next) => {
  res.setHeader(
    "Permissions-Policy",
    "geolocation=(), camera=(), microphone=(), payment=(self), fullscreen=(self)"
  );
  next();
});

Security impact: Helmet normalises casing and prevents duplicate Referrer-Policy declarations. Registering the Permissions-Policy middleware before your routes guarantees it runs on every response, including error handlers downstream.

Verify: curl -sI http://localhost:3000 | grep -iE 'referrer-policy|permissions-policy'

Cloudflare

Use a Transform Rule (Rules → Transform Rules → Modify Response Header) to Set both headers, or a Worker for programmatic control:

export default {
  async fetch(request) {
    const response = await fetch(request);
    const headers = new Headers(response.headers);
    headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
    headers.set(
      "Permissions-Policy",
      "geolocation=(), camera=(), microphone=(), payment=(self), fullscreen=(self)"
    );
    return new Response(response.body, {
      status: response.status,
      statusText: response.statusText,
      headers,
    });
  },
};

Security impact: Edge injection enforces the policy even when the origin is misconfigured, and headers.set (not append) overwrites any origin value so you never ship duplicates. The body stream is preserved untouched.

Verify: curl -sI https://example.com | grep -i referrer-policy

Verification & Diagnostic Workflows

  1. Header presence and exact value:

    curl -sI https://example.com | grep -iE 'referrer-policy|permissions-policy'

    Expected:

    referrer-policy: strict-origin-when-cross-origin
    permissions-policy: geolocation=(), camera=(), microphone=(), payment=(self), fullscreen=(self)
    
  2. DevTools: Network tab → select the document request → Response Headers. Confirm a single instance of each header (duplicates indicate two layers both setting them).

  3. Referrer behaviour: Navigate from your HTTPS origin to a third-party HTTPS page and read document.referrer in that page’s console. Under strict-origin-when-cross-origin expect only https://example.com/, never a path.

  4. Feature denial: In a cross-origin iframe, run navigator.geolocation.getCurrentPosition(console.log, console.error). Expect a GeolocationPositionError with code 1 (PERMISSION_DENIED) when geolocation=() is set.

  5. CI/CD gate:

    #!/usr/bin/env bash
    set -euo pipefail
    h=$(curl -sI "https://staging.example.com")
    echo "$h" | grep -iq 'referrer-policy: strict-origin-when-cross-origin' || { echo "Referrer-Policy missing/wrong"; exit 1; }
    echo "$h" | grep -iq 'permissions-policy:.*geolocation=()' || { echo "Permissions-Policy missing geolocation deny"; exit 1; }

Troubleshooting, Misconfigurations & Safe Rollback

Safe rollback: these headers are non-destructive — reverting is a config change plus a cache purge, with no client-side lock-in (unlike HSTS preload). To roll Referrer-Policy back to the broadest compatible value, set Header always set Referrer-Policy "no-referrer-when-downgrade", purge the CDN, and re-run the curl -sI check. For Permissions-Policy, remove individual feature=() entries to re-enable a capability without dropping the rest of the policy.

Frequently Asked Questions

Does omitting a feature from Permissions-Policy deny it? No. Each feature has its own spec default, and several default to allow for the top-level document. To guarantee denial you must list the feature explicitly with an empty allowlist, e.g. geolocation=().

Is Permissions-Policy a replacement for Feature-Policy? Yes. Permissions-Policy supersedes the deprecated Feature-Policy header and uses a different structured-field grammar (quoted origin strings, feature=(allowlist)). Do not ship both; emit only Permissions-Policy.

Will strict-origin-when-cross-origin break my analytics? It preserves the origin cross-origin, so platforms that attribute by referring origin keep working. Tools that rely on full referring URLs lose path detail; move that attribution to first-party parameters or server-side logging.

Do these headers need to be set if browsers already default to them? Yes. Browser defaults are not contractual and differ across privacy-hardened and legacy agents. Explicit headers give deterministic, auditable behaviour and satisfy compliance scans.

Can I set these via a <meta> tag instead? Referrer-Policy has a <meta name="referrer"> equivalent, but it is overridden by the HTTP header and is weaker. Permissions-Policy has no meta equivalent. Set both as HTTP response headers.

Does the port number appear in the Referer under origin-only policies? Yes. The emitted origin is the full scheme-host-port tuple, so a service on https://app.example.com:8443 sends https://app.example.com:8443/ as the referrer. Only the default port for the scheme is omitted.

Why is my feature still allowed after I set feature=*? * allows every origin, so it grants rather than denies. The wildcard is written bare (no quotes). To deny a feature everywhere use the empty allowlist feature=().