LEARNING OBJECTIVES ⌵
- Master the RFC 8941 / RFC 9651 Structured Field Values grammar underpinning Permissions Policy.
- Correctly construct allowlist tokens:
()(disable),*(wildcard),self, and explicit quoted origin strings. - Combine multiple capability directives into a comma-delimited HTTP response header string.
- Understand the exact parsing rules for origin matching, scheme mismatches, and subdomain boundaries.
📖 The Mental Model & Story (Intuitive Foundation)
Think of a traditional security guard checking credentials at an international airport terminal. If the instructions given to the guard are written in ambiguous, unstructured prose—such as "Only let staff through, or maybe airlines from Paris, but no one without a badge except on Tuesdays"—the guard will inevitably misinterpret the instructions, creating security holes.
To prevent this ambiguity, modern airports use a standardized electronic credential database where every rule is strictly typed:
Zone: Terminal-B-Lounge
Authorized-Entities: (Self, "https://partner-airlines.com")
Emergency-Access: False
+-------------------------------------------------------------------------------+
| OLD FEATURE POLICY (Whitespace-delimited, Fragile, Custom Parser) |
| Feature-Policy: camera 'none'; microphone 'self' https://partner.com; |
| |
| NEW PERMISSIONS POLICY (RFC 8941 Structured Fields Dictionary) |
| Permissions-Policy: camera=(), microphone=(self "https://partner.com") |
+-------------------------------------------------------------------------------+
The IETF standardized this structured approach in RFC 8941: Structured Field Values for HTTP. The Permissions Policy HTTP header is formatted as a Structured Dictionary, where:
- Each key is a standardized feature name (e.g.,
camera,geolocation,payment). - Each value is an allowlist expressed as an ordered list of origin tokens or strings enclosed in parentheses.
Because every modern browser uses the exact same RFC 8941 parsing engine, there is zero ambiguity, zero whitespace parsing errors, and consistent enforcement across Chrome, Firefox, Safari, and Edge.
Technical Deep Dive & Specifications
The RFC 8941 Structured Field Dictionary Grammar
Under the Permissions Policy specification, the HTTP response header value is parsed as a dictionary:
Permissions-Policy: <directive-1>=<allowlist>, <directive-2>=<allowlist>, ...
Each directive consists of a feature identifier followed by = and an allowlist definition.
Permissions-Policy Header Structure
Permissions-Policy: camera=(self), geolocation=(), payment=(self "https://pay.stripe.com")
\____/\_____/ \_________/\__/ \_____/\_____________________________/
| | | | | |
Feature Allowlist Feature Allowlist Feature Allowlist
Name (Self) Name (Disabled) Name (Self + Explicit Origin)
Allowlist Target Identifiers
The allowlist determines which origins are permitted to execute the feature. The table below illustrates the four primary allowlist targets:
| Allowlist Target | Syntax Example | Technical Meaning & Origin Scope |
|---|---|---|
| Disable Completely | feature=() |
The feature is disabled for all origins, including the top-level origin itself and all embedded <iframe> elements. |
| Origin Self Only | feature=(self) or feature=self |
The feature is allowed only for the document's own origin (identical scheme, host, and port). All cross-origin frames are blocked by default. |
| Universal Wildcard | feature=* |
The feature is allowed for the top-level origin and any cross-origin iframe loaded in the document tree. |
| Explicit Origin List | feature=(self "https://example.com") |
The feature is allowed for self and explicitly listed origin string literals. Explicit origins must be enclosed in double quotes. |
Detailed Syntax Matrix & Common Misconfigurations
| Intent | ✅ Correct Permissions-Policy Header | ❌ Incorrect / Legacy / Broken Syntax |
|---|---|---|
| Disable Camera | Permissions-Policy: camera=() |
Permissions-Policy: camera='none' (Legacy syntax) |
| Allow Self for Mic | Permissions-Policy: microphone=(self) |
Permissions-Policy: microphone='self' (Invalid quotes) |
| Allow Multiple Origins | Permissions-Policy: geolocation=(self "https://maps.google.com") |
Permissions-Policy: geolocation=(self, https://maps.google.com) (Unquoted origin & comma error) |
| Allow All Frames for Fullscreen | Permissions-Policy: fullscreen=* |
Permissions-Policy: fullscreen=(*) or fullscreen='*' |
| Combine Multiple Directives | Permissions-Policy: camera=(), microphone=(), payment=(self) |
Permissions-Policy: camera=(); microphone=(); payment=(self) (Semicolons are invalid) |
Origin Matching & Normalization Rules
When the browser evaluates whether an origin matches an entry in a Permissions Policy allowlist, it applies strict Same-Origin normalization:
Allowlist Entry: "https://auth.example.com"
Target Origin: "https://auth.example.com:443" ===> ✅ MATCH (Default HTTPS port 443)
Target Origin: "http://auth.example.com" ===> ❌ REJECT (Scheme mismatch: HTTP != HTTPS)
Target Origin: "https://api.example.com" ===> ❌ REJECT (Subdomain mismatch)
Target Origin: "https://auth.example.com:8443" ===> ❌ REJECT (Port mismatch)
- Origin Strings Must Be Canonical: You cannot specify pathnames, query parameters, or fragments.
- ✅
"https://example.com" - ❌
"https://example.com/checkout"(Invalid: contains path) - ❌
"https://example.com?v=1"(Invalid: contains query)
- ✅
- Wildcards in Hostnames Are Forbidden:
- ❌
"https://*.example.com"(Wildcard subdomains are not permitted by RFC 8941 grammar; every subdomain must be explicitly enumerated).
- ❌
💻 Interactive Code Playground
Starter Code
Save the following complete Node.js / Express server as server.js. This demonstrates how to construct, serve, and dynamically validate RFC 8941 Permissions-Policy headers across different API routes.
Line-by-Line Code Breakdown
- Lines 10–22: Builds a structured array of directives. Features that must be completely blocked (
camera,microphone,usb) use empty parentheses(). - Lines 12–13: Features allowed for
selfand specific partner origins use(self "https://..."). Notice the origin is enclosed in double quotes. - Lines 14:
fullscreen=*uses the wildcard token to allow full-screen video in any embedded frame. - Line 22 (
join(', ')): Joins all directives with commas according to the RFC 8941 dictionary grammar. - Lines 63–76: The client-side script probes
document.permissionsPolicy.allowsFeature()and verifies that the live engine enforcement matches the server's policy.
Expected Browser Render Output
/**
* Permissions Policy Demonstration Server
* Run via: node server.js
*/
const http = require('http');
const server = http.createServer((req, res) => {
// Define strict Permissions-Policy header using RFC 8941 Structured Fields
const permissionsPolicy = [
'camera=()',
'microphone=()',
'geolocation=(self "https://trusted-maps.example.com")',
'payment=(self "https://checkout.stripe.com")',
'fullscreen=*',
'display-capture=()',
'accelerometer=()',
'gyroscope=()',
'magnetometer=()',
'usb=()',
'serial=()'
].join(', ');
// Set standard security headers
res.writeHead(200, {
'Content-Type': 'text/html; charset=UTF-8',
'Permissions-Policy': permissionsPolicy,
'X-Content-Type-Options': 'nosniff'
});
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Permissions Policy Syntax Testbed</title>
<style>
body { font-family: system-ui, sans-serif; background: #0f172a; color: #f8fafc; padding: 2rem; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1.5rem; max-width: 800px; margin: 0 auto; }
pre { background: #090d16; color: #38bdf8; padding: 1rem; border-radius: 6px; overflow-x: auto; font-size: 0.9rem; }
.token-keyword { color: #f43f5e; font-weight: bold; }
.token-origin { color: #10b981; }
.token-feature { color: #fbbf24; }
</style>
</head>
<body>
<div class="card">
<h1>🛡️ Active Permissions Policy Header</h1>
<p>The following RFC 8941 Structured Header was delivered in this HTTP response:</p>
<pre>${permissionsPolicy}</pre>
<h2>Live Capability Probe</h2>
<p>Checking browser enforcement of declared allowlists:</p>
<ul id="results"></ul>
</div>
<script>
const results = document.getElementById('results');
const tests = [
{ name: 'camera', expected: false },
{ name: 'microphone', expected: false },
{ name: 'geolocation', expected: true },
{ name: 'fullscreen', expected: true },
{ name: 'usb', expected: false }
];
tests.forEach(test => {
let allowed = false;
if (document.permissionsPolicy && document.permissionsPolicy.allowsFeature) {
allowed = document.permissionsPolicy.allowsFeature(test.name);
}
const li = document.createElement('li');
const passed = allowed === test.expected;
li.innerHTML = \`<strong>\${test.name}:</strong> \${allowed ? 'ALLOWED' : 'BLOCKED'} — \` +
\`<span style="color: \${passed ? '#10b981' : '#ef4444'}">\${passed ? 'PASS (Matches Policy)' : 'FAIL'}</span>\`;
results.appendChild(li);
});
</script>
</body>
</html>`;
res.end(html);
});
server.listen(3000, () => {
console.log('Testbed running at http://localhost:3000');
});🛡️ Active Permissions Policy Header
-----------------------------------------------------------------------------
camera=(), microphone=(), geolocation=(self "https://trusted-maps.example.com"), payment=(self "https://checkout.stripe.com"), fullscreen=*, display-capture=(), accelerometer=(), gyroscope=(), magnetometer=(), usb=(), serial=()
Live Capability Probe:
• camera: BLOCKED — PASS (Matches Policy)
• microphone: BLOCKED — PASS (Matches Policy)
• geolocation: ALLOWED — PASS (Matches Policy)
• fullscreen: ALLOWED — PASS (Matches Policy)
• usb: BLOCKED — PASS (Matches Policy)🏋️ Hands-On Exercise
🎯 The Challenge: Build a Permissions-Policy Header Builder & Validator
Scenario: You are building an internal CLI tool for your infrastructure team. The tool takes an object representing desired feature permissions and generates a syntactically valid RFC 8941 Permissions-Policy header. If a developer attempts to use invalid tokens (like 'none', 'self', unquoted URLs, or wildcard subdomains), your validator must throw a descriptive syntax error.
Instructions:
- Write a function
generatePermissionsPolicy(config)whereconfigmaps feature names to permission rules ('none','self','*', or array of origins). - Transform
'none'to(). - Transform
'self'to(self). - Transform
'*'to*. - Transform arrays of origins (e.g.
['self', 'https://api.stripe.com']) to(self "https://api.stripe.com"). - Validate that any custom origin begins with
https://and does not contain wildcard domains*..
🏁 Starter Code Sandbox
⚠️ Common Pitfalls
- Using Commas Inside the Allowlist Parentheses: Writing
payment=(self, "https://stripe.com")is invalid. Inside the parentheses, origins must be space-separated (e.g.,payment=(self "https://stripe.com")). Directives themselves are comma-separated. - Wrapping
selfin Quotes: Writingcamera=("self")treats"self"as an origin string namedhttps://selfinstead of the keyword tokenself. Writecamera=(self). - Using Semicolons as Directive Separators: Semicolons were used in the deprecated
Feature-Policyheader.Permissions-Policyuses commas,as dictionary entry separators.
💡 Pro Tips
- Automate Header Generation via Middleware: In Node.js or Next.js, never write long Permissions-Policy header strings by hand. Use a typed configuration object that compiles to RFC 8941 at build time.
- Split Across Multiple Lines with HTTP Header Folding (HTTP/2 / HTTP/3): For servers emitting large lists, modern HTTP/2 and HTTP/3 multiplexed streams compress duplicate header keys via HPACK/QPACK, keeping byte overhead near zero.
- Pair
(self)with Strict Subresource Integrity (SRI): Allowing(self)allows any script executing in the top-level origin. Ensure third-party scripts loaded into the top-level origin are signed with SRI to prevent supply-chain tampering.
📌 Key Takeaways
- Permissions Policy uses IETF RFC 8941 Structured Field Values (dictionary of lists).
feature=()disables a capability across all origins and frames.feature=(self)limits capability to the current origin only.feature=*allows the capability in all top-level and embedded origins.- Explicit origins must be enclosed in double quotes (e.g.,
feature=(self "https://trusted.com")) and separated by spaces inside parentheses. - Directives are separated by commas
,(not semicolons;). - --