Permissions-Policy: microphone=() Blocks Your Own Origin Too
This article may contain affiliate links. Its content is not affected by advertising.
In short
Permissions-Policy: microphone=() allows the microphone for no origin, not even your own, so same-origin calls get rejected at the policy stage before any prompt appears.
The answer
Permissions-Policy: microphone=() means “the microphone feature is allowed for no origin” — and that includes your own. Added as a defense-in-depth header, it completely blocked our teacher-facing voice input feature (built on the Web Speech API), without the browser’s permission prompt ever appearing. Switching to microphone=(self), which puts our own origin on the allow-list, fixed it.
The symptom
As part of hardening our security headers, we added a Permissions-Policy that disables browser features the app doesn’t use.
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
camera and geolocation caused no issues, since the app doesn’t use either. microphone was different: it’s used by lib/teacher-input/use-speech-to-text.ts, which lets teachers dictate input via the Web Speech API. Once this header shipped to production, attempting voice input immediately showed “Microphone access not permitted” — with no browser permission dialog ever appearing.
The cause
Permissions-Policy is evaluated before the browser gets to the point of asking the user for microphone permission. The contents of microphone=()’s parentheses are an allow-list of origins, and an empty list means no origin is allowed — including the one serving the page itself. Unless self is explicitly listed, the page’s own origin is not exempt.
So calling the Web Speech API (SpeechRecognition) never reaches the normal user-permission flow at all — it’s rejected outright as a policy violation.
recognition.onerror = (event) => {
setError(event.error);
};
event.error comes back as "not-allowed". The UI just converted that string into a generic error message, so there was no way to distinguish “the user clicked Deny” from “the policy rejected this before the user ever saw a prompt” — which is why it took a while to trace the cause back to the header.
The fix
camera and geolocation stayed at () — a full block — since the app doesn’t use them. Only microphone, which is actually in use, was changed to include self.
-{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
+{ key: "Permissions-Policy", value: "camera=(), microphone=(self), geolocation=()" },
Adding self exempts only same-origin use from the policy violation. That let the Web Speech API call pass the policy stage and reach the browser’s normal permission prompt. Other origins are still blocked from using the microphone, so the defense-in-depth effect is preserved.
Preventing it next time
The policy of blocking unused features with () hasn’t changed. What changed is the process: whenever a feature you actually use gets added to the policy, explicitly list the origins that use it with self. We left that as a comment at the point of configuration.
// Disable browser features we don't use, and allow only our own origin for
// the ones we do (least privilege).
// microphone is used by F02 teacher voice input (Web Speech API,
// `lib/teacher-input/use-speech-to-text.ts`) — with `microphone=()` (full
// block), the policy rejects it before the browser ever shows a permission
// prompt, producing a "not allowed" error. `microphone=(self)` allows only
// our own origin.
// camera / geolocation are unused, so they stay fully blocked with `()`
// (defense in depth / least privilege).
The goal of a security header — blocking unneeded features — and the side effect of catching a feature you actually use turned out to be a combination that needs re-checking every single time a new feature is added.
Frequently asked questions
Q1Why did the browser's permission prompt never show up at all?
Permissions-Policy is evaluated before the browser's own permission prompt. microphone=() allows no origin at all, including the one serving the page, so the flow never reaches the step where the user is asked — the API call is simply rejected as a policy violation.
Q2Did you fix camera and geolocation too?
No. The app doesn't use either, so both stayed at () with a full block. Only microphone, which the app actually uses, was changed. Loosening features the app doesn't use would undermine the point of having a policy in the first place.
Q3Couldn't the app's own error handling have caught this?
It can detect a failure, just not why. onerror passes "not-allowed" as event.error, and the UI turned that into a generic "microphone access not permitted" message — indistinguishable from a user clicking Deny.
Q4What changes with microphone=(self)?
self adds your own origin to the allow-list. External origins remain blocked as before, but pages served from your own origin now proceed all the way to the browser's normal permission prompt, where the user can allow or deny.
Environment verified
- Next.js 16.2.6 / React 19.2.6
- Occurred and fixed in production on 2026-06-05
What this article is based on
- TypeScript file lines 60-60commit cdeb255
- TypeScript file lines 64-64commit 37a19de
- TypeScript file lines 70-90commit 37a19de
- TypeScript file lines 113-120commit b3ea98d
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.