🍱 Lunchbox Hands

getusermedia

Why Your Browser Can't Access Your Microphone (And Why the Usual Advice Doesn't Help)

Every getUserMedia error name, what actually causes it, and the failure mode nobody writes about: a response header that kills the permission prompt before it ever appears.

Search for “browser won’t access my microphone” and you will get the same article fifteen times: check the padlock icon, check your OS privacy settings, restart the browser. That advice covers exactly one of the seven distinct ways getUserMedia fails — and there is a failure mode where no amount of clicking browser settings can ever work, because the page told the browser to refuse before you got a prompt at all.

We know that one first-hand. We shipped it. More on that below.

First: the permission prompt is the last step, not the first

The mental model most people have is “the site asks, I say yes, it works.” The real sequence has four gates, and the prompt is gate three:

  1. Secure context. navigator.mediaDevices is undefined on plain HTTP. Not an error — undefined. Your code throws TypeError: Cannot read properties of undefined, which looks like a bug in your script, not a permissions problem. Only HTTPS and localhost count.
  2. Permissions Policy. The document must be allowed to use the feature at all, per the Permissions-Policy response header and, in an iframe, the allow attribute. Fail here and the browser rejects without showing a prompt.
  3. User permission. The prompt itself, plus whatever the user previously chose and the browser remembered.
  4. The hardware actually opening. The OS has to hand the device over, and it may refuse.

Every guide on the internet addresses gate 3. Gates 1, 2, and 4 produce different symptoms and need different fixes.

The error taxonomy

getUserMedia rejects with a DOMException whose name tells you which gate you hit. This is the table worth bookmarking:

error.nameWhat actually happenedWhat fixes it
NotAllowedErrorUser dismissed or denied the prompt — or Permissions Policy blocked the feature, or the OS denied the browser itselfReset the site permission; if no prompt ever appeared, it’s Permissions Policy or the OS
NotFoundErrorNo device matches the constraints. There is no microphone, or you asked for one that isn’t therePlug something in; loosen constraints
NotReadableErrorThe device exists and you’re allowed to use it, but the OS or hardware couldn’t start it — usually another app has it exclusivelyClose Zoom/Teams/OBS; on Windows, check exclusive-mode drivers
OverconstrainedErrorThe device exists but can’t satisfy a exact/min/max constraint you setRead error.constraint — it names the offending one — and relax it
SecurityErrorMedia capture is disabled in the documentRare; usually a sandboxed iframe
AbortErrorSomething else went wrong at the hardware level, not covered aboveRetry; check OS audio stack
TypeErrorYou passed constraints where both audio and video are false or absentFix the call

The critical, non-obvious one is that NotAllowedError is overloaded. It means both “the user said no” and “the page was never permitted to ask.” Those need opposite fixes, and the error object doesn’t distinguish them. The tell is behavioral: if you never saw a prompt, you were blocked at gate 2, and telling the user to check their browser settings will send them in circles forever.

The one that bit us: Permissions-Policy: microphone=()

Here’s the part we haven’t seen written up honestly anywhere, so: our own mistake.

Permissions-Policy is a response header that declares which powerful features a document may use. It takes an allowlist per feature:

Permissions-Policy: geolocation=(), microphone=(self), camera=(self)

That reads: no origin may use geolocation, and only this origin may use the microphone and camera. The syntax is compact enough to be actively dangerous, because an empty allowlist () means no origin at all — including your own site. It does not mean “default” and it does not mean “only me.” It means off, everywhere.

Our security headers were written long before we had any tool that needed a microphone, and they said:

Permissions-Policy: geolocation=(), microphone=(), camera=(self)

The camera=(self) had been fixed earlier when we shipped a QR-code scanner. The microphone was still hard-off. Then we shipped a mic test tool.

The result would have been perfect, silent failure in production: getUserMedia rejects with NotAllowedError, no prompt is ever shown, and — this is the genuinely bad part — our own on-page troubleshooting copy would have told users to check their browser permission settings, advice that could not possibly work, because the permission was being refused by a header the user has no control over. A tool whose entire job is diagnosing your hardware would have been confidently blaming the user’s hardware.

Two things made it nearly invisible:

  • astro dev doesn’t apply public/_headers. Cloudflare Pages serves that file in production; the local dev server ignores it. Every local test passed. (We wrote about this class of bug before in the CSP context — same root cause, different header.)
  • The header file wasn’t part of the change. Nobody diffs a file they didn’t touch. It took a review of the whole branch rather than the individual commits to catch it.

If you ship anything that uses a gated device — microphone, camera, geolocation, screen capture — grep your response headers before you ship, and verify the header on the deployed origin, not in dev:

curl -sI https://example.com/your-page | grep -i permissions-policy

You can inspect any site’s live security headers with our HTTP header analyzer if you’d rather not curl.

The iframe variant

Same trap, different surface. Even with a permissive top-level policy, a cross-origin iframe gets the feature denied by default. The embedding page has to opt in explicitly:

<iframe src="https://tool.example.com" allow="microphone; camera"></iframe>

Miss that and you get the identical symptom — NotAllowedError, no prompt — for embedded widgets that work fine standalone.

The Safari trap: your meter is dead but your mic is fine

Suppose permission is granted and the stream is live. On WebKit you can still get a level meter pinned at silence, which reads exactly like a broken microphone.

The cause is that Safari hands back an AudioContext in the suspended state. It requires a user gesture to start, and it does not throw, warn, or log anything — the audio graph is wired up correctly and simply never advances. Your analyser node returns silence forever.

const ctx = new AudioContext()
// Safari: ctx.state === 'suspended' here, and stays that way
if (ctx.state === 'suspended') await ctx.resume()

One line. Without it, a working microphone reports as dead in Safari and every iOS browser (all of which are WebKit under the hood). It’s worth checking specifically, because “works in Chrome” is not evidence here.

enumerateDevices lies before you have permission

One more that trips up device pickers. navigator.mediaDevices.enumerateDevices() works without permission, but it returns entries with empty label strings and, in current browsers, a single generic entry per device kind. That’s deliberate — the device list itself is a fingerprinting surface, and it’s one of the signals you can see exposed on our browser fingerprint test.

The consequence: if you build a “choose your microphone” dropdown and populate it on page load, you get a list of blanks. You have to call getUserMedia first, then enumerate again. Labels appear only after permission is granted.

A debugging order that actually converges

When a user reports “your site can’t see my mic,” run it in this order — each step rules out one gate:

  1. Did a prompt ever appear? No prompt → Permissions Policy, iframe allow, or an OS-level block on the browser itself. Not a site permission.
  2. Is the page on HTTPS? Check navigator.mediaDevices is defined at all.
  3. What’s error.name? Log it. NotReadableError means “something else has the device” — a completely different conversation than NotAllowedError.
  4. Does another app have it open? This is the single most common cause of NotReadableError, and on Windows exclusive-mode audio drivers make it common.
  5. Is it Safari? Check for a suspended AudioContext before believing a silent meter.
  6. Did the device get unplugged? A MediaStreamTrack fires an ended event when its source disappears. If you don’t listen for it, a USB headset pulled mid-session leaves you with a live-looking stream producing nothing.

That last one is worth internalizing generally: a diagnostic tool that fails silently is worse than one that fails loudly, because the user trusts the readout. A meter at zero and a meter that stopped updating look identical.

Test it yourself

  • Mic Test — live level meter, scrolling waveform, sample rate readout, device switching, and record-and-play-back so you can hear what the other end hears.
  • Webcam Test — live preview with the negotiated resolution and a measured frame rate, not the resolution you asked for.
  • HTTP Header Analyzer — check the Permissions-Policy any site is actually sending.

Both device tests run entirely in the browser; no audio or video is uploaded anywhere.