Every response from our site, including the 404s and the font files, carries this:

permissions-policy: camera=(), microphone=(), geolocation=() 

62 bytes. It is four lines in next.config.mjs, one of six headers we set on /:path*, and nobody has looked at it since it was written. You can see it working, on a live page, in your own console. Open the round planner and run:

navigator.geolocation.getCurrentPosition( () => console.log("granted"), e => console.log(e.code, e.message) ); 

It prints:

1 Geolocation has been disabled in this document by permissions policy. 

No permission prompt, no user decision, no network. The same for the camera:

await navigator.mediaDevices.getUserMedia({ video: true }) // NotAllowedError: Permission denied 

That is the whole policy working exactly as intended. The interesting part is the question I had never asked: what is the denominator?

80 features, 3 denied

Chrome exposes the full list to the page itself:

const fp = document.featurePolicy; const feats = fp.features(); console.log(feats.length, feats.filter(f => !fp.allowsFeature(f))); 

On our site that prints 80 and ["camera", "geolocation", "microphone"].

So the header denies three things and leaves 77 allowed on a marketing site whose entire interactive surface is four calculators that make no network requests. The allowed list, in full, is worth reading slowly:

accelerometer, aria-notify, autoplay, bluetooth, browsing-topics, captured-surface-control, ch-* (27 client hint features), clipboard-read, clipboard-write, compute-pressure, cross-origin-isolated, deferred-fetch, deferred-fetch-minimal, digital-credentials-create, digital-credentials-get, display-capture, encrypted-media, fullscreen, gamepad, gyroscope, hid, identity-credentials-get, idle-detection, interest-cohort, keyboard-map, language-detector, language-model, local-fonts, local-network, local-network-access, loopback-network, magnetometer, media-playback-while-not-visible, midi, on-device-speech-recognition, otp-credentials, payment, picture-in-picture, private-state-token-issuance, private-state-token-redemption, publickey-credentials-create, publickey-credentials-get, screen-wake-lock, serial, speaker-selection, storage-access, summarizer, sync-xhr, tools, translator, unload, usb, window-management, xr-spatial-tracking 

Our pages are allowed to enumerate serial ports, claim HID devices, open a MIDI session, capture the screen, ask whether the user has gone idle, read the installed font list, and take part in Topics and interest cohorts. None of our code does any of this. Earlier measurements of these same pages found zero Set-Cookie responses, zero analytics and zero stored state, which is also what our cookie policy says. The policy is not describing that posture, it is describing the three APIs that happened to be in the snippet.

For what it is worth the defaults do protect us from the embedded third party. The one cross-origin script on the site is Stripe's pricing table loader, and asking the policy about it:

fp.allowsFeature("usb", "https://js.stripe.com") // false fp.allowsFeature("payment", "https://js.stripe.com") // false fp.allowsFeature("storage-access", "https://js.stripe.com") // true 

Everything defaults to self, except storage-access, whose spec default allowlist is *. So a third-party frame on our origin gets exactly one capability we never granted it, and it is the one that exists for Safari-style storage partitioning.

The four things the browser withholds from us

The other direction is more interesting, because it is a trade we have silently taken.

self.crossOriginIsolated // false typeof SharedArrayBuffer // "undefined" performance.measureUserAgentSpecificMemory // TypeError: not a function 

We send no Cross-Origin-Opener-Policy, no Cross-Origin-Embedder-Policy and no Cross-Origin-Resource-Policy (grep the headers: zero of the three). Without that pair the document is not cross-origin isolated, and Chrome withholds:

  • SharedArrayBuffer, which we have no use for.
  • performance.measureUserAgentSpecificMemory(), which is the only accurate way to answer "how much memory does our page cost", and which I wanted for a post two weeks ago and could not have.
  • A high-resolution clock. Measured on our own page: 20,000 consecutive performance.now() reads produce exactly one non-zero delta value, 0.1 ms. That is the clamped non-isolated resolution. An isolated context gets 5 microseconds, twenty times finer.

What we do get is the old coarse API, which Chrome still exposes:

performance.memory // { jsHeapSizeLimit: 4395630592, totalJSHeapSize: 15395926, usedJSHeapSize: 11024702 } 

A 4.4 GB heap limit and a 15 MB heap, quantised and shared across the whole renderer, which is why an earlier attempt of mine to count our page's memory through a reused tab reported sixteen documents and 2,900 event listeners for a page with 435 elements.

What isolation would cost, exactly

This is the measurable part. Turning on Cross-Origin-Embedder-Policy: require-corp means every cross-origin subresource must opt in. We have exactly one, and its headers are:

HTTP/2 200 content-type: text/javascript; charset=utf-8 access-control-allow-origin: * cache-control: max-age=120 content-length: 10945 

No cross-origin-resource-policy. As the script tag is written today, with no crossorigin attribute, require-corp would block it, the pricing table custom element would never be defined, and the only visible symptom would be on the pricing page. Because Stripe does send access-control-allow-origin: *, adding crossorigin to the tag turns the load into a CORS-checked fetch, which satisfies COEP. So the cost of isolation here is one attribute and a careful look at the one page that embeds anything.

Everything else on the site is same-origin: 32 JavaScript chunks, two stylesheets, two fonts, one SVG logo, all from pub-trivia.app.

What I am going to change

  1. Deny what we never use. usb=(), serial=(), hid=(), bluetooth=(), midi=(), display-capture=(), idle-detection=(), local-fonts=(), browsing-topics=(), interest-cohort=() costs about 150 bytes a response and removes ten capabilities from an attack surface that includes any script we ever add in future, including the one we embed from somebody else.
  2. Keep the three we already deny. The geolocation one in particular is free: our prices are localised from the edge's x-vercel-ip-country header, so the site has never had a reason to ask the browser where it is, and there are zero references to navigator.geolocation in the repository.
  3. Treat cross-origin isolation as a decision rather than an omission, and write down that we chose the embedded pricing table over the memory API.

If you want the denominator for your own site, that three-line snippet in the console is the whole measurement, and it works on any page. On ours you can run it straight from the free tools, which do all their work in the browser, so the Network tab stays empty while you poke at the policy.