Migrating Feature-Policy to Permissions-Policy

The Feature-Policy header was renamed to Permissions-Policy in 2020 and its grammar was rebuilt on HTTP structured fields. This guide is a mechanical translation procedure: it maps the old space-separated, quoted-keyword syntax to the new feature=(allowlist) structured-field syntax, lists the tokens that were renamed or dropped, and shows how to run both headers during the changeover before deleting the legacy one. The conceptual background — what these headers gate and how they relate to privacy controls — lives in the Referrer-Policy and Permissions-Policy reference; this page is purely the migration mechanics.

Configuration Syntax & Exact Values

The two headers express the same intent with incompatible grammars. Here is a representative legacy header and its exact modern equivalent:

Feature-Policy: geolocation 'self' https://maps.example; camera 'none'; microphone 'none'; fullscreen 'self'; payment *
Permissions-Policy: geolocation=(self "https://maps.example"), camera=(), microphone=(), fullscreen=(self), payment=*

Every difference between those two lines is governed by five rules. Applying them in order converts any header:

Two more tokens complete the mapping. The wildcard * stays * but loses any parentheses — payment * becomes payment=* (no parens, meaning all origins). The 'src' keyword existed only in the iframe allow= container context and has no header meaning; drop it when translating a header line.

Feature-Policy to Permissions-Policy token translation Each Feature-Policy token maps to a specific Permissions-Policy structured-field token: none to empty parens, quoted self to bare self, bare origins to double-quoted strings. Feature-Policy token Permissions-Policy token camera 'none' camera=() fullscreen 'self' fullscreen=(self) geolocation https://a.x geolocation=("https://a.x") payment * payment=* a; b (semicolon) a, b (comma) Rule of thumb: quotes move from keywords ('self') onto origins ("https://..") and every value lives inside =( ) except the bare wildcard =*
The mechanical token map: keyword quotes are dropped, origin strings gain double quotes, and each directive is wrapped in an equals-parenthesis allowlist.

Renamed and removed features

Most feature names are identical across both headers: camera, microphone, geolocation, payment, usb, fullscreen, autoplay, encrypted-media, picture-in-picture, accelerometer, gyroscope, and magnetometer translate name-for-name. A minority changed or disappeared, and these are the ones that silently stop working if you copy them across verbatim:

Feature-Policy token Permissions-Policy status
camera, microphone, geolocation, payment, usb, fullscreen, autoplay Unchanged — same name
wake-lock Renamed to screen-wake-lock
speaker Removed — never standardised; no equivalent
vibrate Removed — the Vibration API is not policy-controlled
document-write, lazyload, layout-animations, unsized-media, oversized-images Relocated to the separate Document-Policy header — no Permissions-Policy token
sync-xhr Deprecated feature; prefer Document-Policy where still needed

Copying wake-lock 'self' into a Permissions-Policy header produces wake-lock=(self), which browsers do not recognise; the directive is dropped and the feature falls back to its default. It must be written screen-wake-lock=(self). Tokens in the relocated group are not Permissions-Policy features at all — do not carry them over.

Server-Side Configuration

Emit exactly one Permissions-Policy header from the outermost layer that terminates the response. During transition you may additionally emit Feature-Policy for legacy clients; the blocks below show both so you can delete the old line cleanly in one edit.

Nginx

# During transition: legacy clients read Feature-Policy, modern read Permissions-Policy
add_header Feature-Policy "geolocation 'self' https://maps.example; camera 'none'; microphone 'none'" always;
add_header Permissions-Policy "geolocation=(self \"https://maps.example\"), camera=(), microphone=()" always;

always forces both headers onto 4xx/5xx responses; without it Nginx omits them from internally generated error pages. Double quotes inside the Permissions-Policy value must be backslash-escaped because the whole value is already wrapped in double quotes. Once support is universal, delete the Feature-Policy line — an add_header in a location block replaces inherited headers, so re-declare Permissions-Policy in any location that sets its own.

Apache

# mod_headers required (a2enmod headers)
Header always set Feature-Policy "geolocation 'self' https://maps.example; camera 'none'; microphone 'none'"
Header always set Permissions-Policy "geolocation=(self \"https://maps.example\"), camera=(), microphone=()"

always appends both headers even on internally generated error documents, which the default onsuccess table skips. Escape the inner double quotes with a backslash. Deleting the first line completes the migration.

Cloudflare

Use a Cloudflare Transform Rule (Rules -> Transform Rules -> Modify Response Header) -> Set static for Permissions-Policy with value geolocation=(self "https://maps.example"), camera=(), microphone=(). Inside the dashboard field the double quotes are entered literally — no backslash escaping. During transition add a second Set static action for Feature-Policy; to finish, delete that action and remove any duplicate origin add_header so only the edge copy ships.

Node / Express

Helmet’s permissionsPolicy helper was removed, so set the value directly. Register the middleware before route and error handlers so short-circuited responses still carry the header:

app.use((req, res, next) => {
  res.setHeader(
    'Permissions-Policy',
    'geolocation=(self "https://maps.example"), camera=(), microphone=()'
  );
  // Transition only — remove once legacy clients are gone:
  res.setHeader(
    'Feature-Policy',
    "geolocation 'self' https://maps.example; camera 'none'; microphone 'none'"
  );
  next();
});

Django / FastAPI

django-permissions-policy or a plain middleware sets the modern header; Django ships no Feature-Policy helper. See the Django and FastAPI middleware guide for wiring order.

class PermissionsPolicyMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        response["Permissions-Policy"] = (
            'geolocation=(self "https://maps.example"), camera=(), microphone=()'
        )
        return response

Diagnostic & Verification Steps

Inspect both headers on the wire. During the transition both should appear; after removal, only permissions-policy:

curl -sI https://yourdomain.com | grep -iE 'feature-policy|permissions-policy'

Expected output during transition:

feature-policy: geolocation 'self' https://maps.example; camera 'none'; microphone 'none'
permissions-policy: geolocation=(self "https://maps.example"), camera=(), microphone=()

Expected output after migration:

permissions-policy: geolocation=(self "https://maps.example"), camera=(), microphone=()

Confirm the browser parsed the new header, not the old one. A malformed Permissions-Policy value is silently dropped, so test the effective state rather than trusting the wire string. 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 (). If it returns prompt or granted, the modern header failed to parse — most often because a keyword was left quoted ('none' instead of ()) or an origin was left unquoted.

Catch typos the browser rejects. Chromium logs malformed directives to the Console. Open DevTools -> Console on a fresh load and look for lines such as:

Error with Permissions-Policy header: Unrecognized feature: 'wake-lock'.
Error with Permissions-Policy header: Parse of permissions policy failed because of errors reported by structured header parser.

The first flags a renamed feature (wake-lock -> screen-wake-lock); the second flags a grammar mistake such as a stray semicolon or an unquoted origin. There is no dedicated DevTools panel for this header — verify in the Network tab -> document request -> Response Headers, and confirm there is no duplicate Permissions-Policy line from a second layer.

Verification decision path after migration A flow that starts from the curl output and branches on whether the permissions query returns denied to locate a quoting or feature-name error. curl -sI shows permissions-policy line permissions.query state === 'denied'? no Header dropped as malformed check Console for parse error yes Migration OK parsed Fix the two usual causes quoted keyword left in ('none') or origin left unquoted, then redeploy
If the wire header is present but the permission is not denied, the value failed structured-field parsing — the Console names the exact reason.

Edge Cases, Security Implications & Safe Rollback

  1. Both headers present: Permissions-Policy always wins in modern browsers. When a browser that supports Permissions-Policy receives both headers, it applies Permissions-Policy and ignores Feature-Policy entirely — even for features named in only one of them. A legacy-only browser applies Feature-Policy. This means the two headers must express the same intent; a stricter Feature-Policy will not tighten anything in a current browser, and a looser one will not loosen it. The danger is drift: editing one header and forgetting the other leaves old and new clients on different policies.
  2. Silent failure on renamed features is a security regression. Because an unrecognised feature name is dropped rather than rejected, carrying wake-lock=(self) or speaker=() across produces no error page and no visible symptom — the feature simply reverts to its browser default, which for many APIs is (self), i.e. enabled for your origin. A migration that “looks done” can quietly re-enable a capability you intended to block. Audit every non-core token against the mapping table above.
  3. Structured-field strictness rejects the whole value, not one directive. A single grammar error — a stray semicolon carried over from the old syntax, or an unquoted origin — can cause the structured-field parser to reject the entire header, dropping every directive at once. Unlike CSP, there is no per-directive salvage. Validate the complete string with the DevTools Console before trusting it.

Rollback directive. This migration is non-destructive and instantly reversible; unlike HSTS there is no client-side caching of the policy. To roll back, restore the previous header value (or re-add the Feature-Policy line) and redeploy — the next response takes effect immediately with no persisted state on any client.

How a browser resolves two co-existing feature headers A branch showing that a modern browser applies Permissions-Policy and discards Feature-Policy, while a legacy browser applies Feature-Policy. Response carries both headers Modern browser applies Permissions-Policy Legacy browser applies Feature-Policy Feature-Policy ignored entirely Permissions-Policy not understood Keep both values identical in intent, or clients diverge
A modern browser uses only Permissions-Policy; the legacy header serves shrinking pre-2021 clients until you drop it.

Because Permissions-Policy has shipped in Chromium since version 88 (January 2021) and in current Firefox and Safari, the population of clients that need Feature-Policy is effectively zero. Drop the legacy header once your analytics show no pre-2021 browser traffic — keeping it adds bytes and a second source of policy drift with no security benefit.

Conclusion

Stage the migration by adding the Permissions-Policy header alongside the existing Feature-Policy in a non-production environment, then verify with curl and a navigator.permissions.query check that each translated directive parses and denies as intended. Ship both headers to production briefly so any remaining legacy clients keep their protection, watch the DevTools Console for parse errors and your analytics for pre-2021 traffic, then delete the Feature-Policy line in a single follow-up deploy. The whole change is reversible on the next response, so promotion carries no caching risk.

Frequently Asked Questions

Do I need to keep the Feature-Policy header for any current browsers? No. Every current release of Chrome, Edge, Firefox, and Safari reads Permissions-Policy, which has shipped since Chromium 88 in January 2021. Keep Feature-Policy only if analytics show meaningful pre-2021 traffic, and delete it otherwise to avoid policy drift.

If I serve both headers with different values, which one applies? A browser that understands Permissions-Policy applies it and ignores Feature-Policy completely, even for features listed in only one header. A legacy browser applies Feature-Policy. Keep the two values identical in intent, or old and new clients end up on different policies.

Why did my feature stop being blocked after I copied the directive across? Almost always a renamed or removed token. wake-lock became screen-wake-lock, and speaker, vibrate, and several image-loading features have no Permissions-Policy equivalent. An unrecognised feature name is silently dropped and reverts to its browser default, which for many APIs is (self).

How do I translate a quoted origin from Feature-Policy? Move the quoting. Feature-Policy listed origins bare (https://x.example) and quoted keywords ('self'). Permissions-Policy drops the keyword quotes (self) and double-quotes each origin ("https://x.example"), placing them space-separated inside =( ).

Can one bad directive break the whole Permissions-Policy header? Yes. Unlike CSP, the structured-field parser can reject the entire header value on a single grammar error such as a leftover semicolon or an unquoted origin, dropping every directive at once. Validate the complete string in the DevTools Console before relying on it.