Rebounder Tech Blog

Written by the people who actually run these systems in production.

Permissions-Policy: microphone=() Blocks Your Own Origin Too

公開 読了時間 約3分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

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.

よくある質問

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.

確認した環境

  • Next.js 16.2.6 / React 19.2.6
  • Occurred and fixed in production on 2026-06-05

この記事の根拠

  • TypeScriptファイル 60〜60行目コミット cdeb255
  • TypeScriptファイル 64〜64行目コミット 37a19de
  • TypeScriptファイル 70〜90行目コミット 37a19de
  • TypeScriptファイル 113〜120行目コミット b3ea98d

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。