widget_chat is live on pub.dev — drop-in AI chat for Flutter, FlutterFlow, React & Web. Start free →

← Back to Blog
Fix Flutter Web Mic NotAllowedError in Voice Chat

Fix Flutter Web Mic NotAllowedError in Voice Chat

flutter-webmicrophonevoice-aipermissions-policyflutterflow

Fix Flutter Web Mic NotAllowedError in Voice Chat

Your voice assistant works on iOS and Android. You run flutter build web, deploy, tap the mic button — and nothing happens. No browser prompt, no error dialog, just a dead button and NotAllowedError in the DevTools console. This is the single most common failure when shipping a voice-enabled AI support assistant to Flutter Web, and it is almost never a bug in your Dart code.

Here is the decision tree, keyed to the exact browser error, plus the three fixes and a real preflight you can paste in.

permission_handler isn't lying to you — it just can't tell you what broke

First, kill the myth that you're missing a package. permission_handler does ship a web implementation (permission_handler_html). For Permission.microphone it calls navigator.mediaDevices.getUserMedia({ audio: true }) and immediately stops the returned tracks so the recording indicator doesn't stay lit. It supports exactly four permissions on web — microphone, camera, notification, location — and throws UnsupportedError for anything else.

The problem is what it does with failures. Its state mapping is:

Browser state PermissionStatus
granted granted
denied permanentlyDenied
prompt denied
any thrown error permanentlyDenied

So an insecure origin, a missing allow="microphone" on the iframe, a macOS privacy block, and an actual user click on "Block" all collapse into the same permanentlyDenied. And the recovery you'd reach for on mobile — openAppSettings() — returns false and does nothing on web. That's the dead end. To fix a web mic you need the DOMException name, which means calling getUserMedia yourself.

The decision tree

Open DevTools, tap the mic, and read the console. Then match:

Symptom Root cause Fix
TypeErrornavigator.mediaDevices is undefined Insecure context (plain http:// on a non-localhost host) Fix 1
NotAllowedError with no prompt ever shown Permissions Policy blocked it (cross-origin iframe) Fix 2
NotAllowedError after a prompt, or on a fresh profile User denied, or OS-level block Fix 3
NotFoundError / OverconstrainedError No input device matches the constraints Ask the user to plug one in
NotReadableError / AbortError Device busy or blocked by OS/browser layer Fix 3
SecurityError Media capture disabled at the user-agent level Enterprise policy / browser settings

The critical tell: NotAllowedError with no prompt is a policy problem, not a user problem. A Permissions Policy block fails identically to a denial, by design.

Fix 1: secure context — why localhost works and the LAN IP doesn't

getUserMedia() is gated behind secure contexts. https://, http://localhost, http://127.0.0.1, and file:// count. Everything else does not, and in an insecure context navigator.mediaDevices isn't merely restricted — it's undefined, so the call throws a plain TypeError before any permission logic runs.

That's why flutter run -d chrome (which serves on http://localhost:PORT) works fine, and testing the same build from your phone at http://192.168.1.24:8080 silently fails.

For real device testing on your LAN, either serve over HTTPS, or whitelist the origin for a dev session:

# Serve on the LAN, then tell Chrome to trust that exact origin
flutter run -d chrome \
  --web-hostname 0.0.0.0 --web-port 8080 \
  --web-browser-flag="--unsafely-treat-insecure-origin-as-secure=http://192.168.1.24:8080" \
  --web-browser-flag="--user-data-dir=/tmp/flutter-mic-profile"

Two things to know about flutter run -d chrome: it launches a fresh temporary Chrome profile, so any mic grant you made in your everyday browser doesn't exist there, and on macOS that new Chrome process may not yet have OS-level mic access (see Fix 3). Never conclude "my code is broken" from that window alone — test a flutter build web output served over real HTTPS before you start rewriting Dart.

Fix 2: the cross-origin iframe (the one that produces no prompt)

If your Flutter Web build is embedded in a customer portal, a docs site, or a builder preview panel, you're in an iframe — and Chromium has blocked camera/mic in cross-origin iframes by default since Chrome 64. The default allowlist for the microphone feature is self, meaning the top document's origin only.

Both sides must agree. Two changes are required:

1. The host page's embed tag must delegate the feature:

<iframe
  src="https://app.example.com/"
  allow="microphone; autoplay"
  title="Support assistant">
</iframe>

2. The host page's Permissions-Policy header must include the embedded origin. The allow attribute can only narrow what the host already has — it can never widen it. So a host sending Permissions-Policy: microphone=(self) blocks your iframe even with the attribute present:

# On the HOST portal that embeds the Flutter app
add_header Permissions-Policy "microphone=(self \"https://app.example.com\")" always;

And make sure your own Flutter Web host isn't shipping a restrictive header of its own. On Firebase Hosting:

{
  "hosting": {
    "public": "build/web",
    "headers": [
      {
        "source": "**",
        "headers": [
          { "key": "Permissions-Policy", "value": "microphone=(self)" }
        ]
      }
    ]
  }
}

Two gotchas: if the iframe also carries a sandbox attribute, you need the sandbox tokens and allow together, and Safari has historically been far stricter about capture inside cross-origin frames than Chromium — test it explicitly rather than assuming parity.

Fix 3: the OS-level block that looks exactly like a user denial

On macOS, Chrome itself needs microphone access from the system. If System Settings → Privacy & Security → Microphone → Google Chrome is off, getUserMedia rejects with NotAllowedError (often with a "Permission denied by system" message) no matter what the site-level setting says. After toggling it, fully quit and relaunch the browser — a reload is not enough. Windows has the same trap under Settings → Privacy & security → Microphone.

Also: Chrome now offers one-off "Allow this time" grants. Never cache granted in your app state across sessions and skip the preflight — re-check every time the user taps the mic.

A real getUserMedia preflight in Dart

This uses package:web and dart:js_interop (dart:html is deprecated and unsupported for Wasm). Put it in a web-only file behind a conditional export so your mobile builds still compile:

// mic_preflight.dart
export 'mic_preflight_stub.dart'
    if (dart.library.js_interop) 'mic_preflight_web.dart';
// mic_preflight_web.dart
import 'dart:js_interop';
import 'dart:js_interop_unsafe';
import 'package:web/web.dart' as web;

enum MicIssue { ok, insecureContext, noMediaDevices, blockedByPolicy, denied, noDevice, busy, unknown }

class MicResult {
  const MicResult(this.issue, this.rawName);
  final MicIssue issue;
  final String rawName;

  String get message => switch (issue) {
    MicIssue.ok => '',
    MicIssue.insecureContext =>
      'Voice needs a secure connection. Open this page over https:// and try again.',
    MicIssue.noMediaDevices =>
      'This browser isn\u2019t exposing microphone APIs here. Reload over https:// to talk.',
    MicIssue.blockedByPolicy =>
      'The page embedding this app hasn\u2019t granted mic access. Ask the site owner to add '
      'allow="microphone" to the embed.',
    MicIssue.denied =>
      'Microphone is blocked. Click the icon at the left of the address bar, set '
      'Microphone to Allow, then reload.',
    MicIssue.noDevice =>
      'No microphone found. Connect one, then tap the mic again.',
    MicIssue.busy =>
      'Your mic is in use by another app, or blocked in your OS privacy settings.',
    MicIssue.unknown =>
      'The microphone couldn\u2019t start. You can keep typing \u2014 the assistant still answers in text.',
  };
}

Future<MicResult> preflightMic() async {
  if (!web.window.isSecureContext) {
    return const MicResult(MicIssue.insecureContext, 'insecure-context');
  }

  // In an insecure context this getter returns undefined -> null in Dart.
  final devices = (web.window.navigator as JSObject)
      .getProperty<JSObject?>('mediaDevices'.toJS);
  if (devices == null) {
    return const MicResult(MicIssue.noMediaDevices, 'undefined');
  }

  // Chromium exposes the resolved Permissions Policy for this document.
  // Absent in Firefox/Safari, so treat null as "unknown, keep going".
  final policy = (web.document as JSObject).getProperty<JSObject?>('featurePolicy'.toJS);
  final allowed = policy
      ?.callMethod<JSBoolean>('allowsFeature'.toJS, 'microphone'.toJS)
      .toDart;
  if (allowed == false) {
    return const MicResult(MicIssue.blockedByPolicy, 'permissions-policy');
  }

  try {
    final stream = await web.window.navigator.mediaDevices
        .getUserMedia(web.MediaStreamConstraints(audio: true.toJS))
        .toDart;
    // We only wanted the grant \u2014 release the device so the tab indicator clears.
    for (final track in stream.getTracks().toDart) {
      track.stop();
    }
    return const MicResult(MicIssue.ok, '');
  } catch (error) {
    final name = _domExceptionName(error);
    return MicResult(
      switch (name) {
        'NotAllowedError' || 'SecurityError' => MicIssue.denied,
        'NotFoundError' || 'OverconstrainedError' => MicIssue.noDevice,
        'NotReadableError' || 'AbortError' => MicIssue.busy,
        _ => MicIssue.unknown,
      },
      name,
    );
  }
}

String _domExceptionName(Object error) {
  if (error is JSObject) {
    final name = error.getProperty<JSString?>('name'.toJS);
    if (name != null) return name.toDart;
  }
  return error.toString();
}

Call it from the mic button's onPressed, before any other await. Safari is strict about transient activation, and the activation window is short — if you await a config fetch or an analytics call first, the prompt can silently never appear.

IconButton(
  icon: const Icon(Icons.mic),
  onPressed: () async {
    final result = await preflightMic();   // first await, straight off the tap
    if (result.issue != MicIssue.ok) {
      setState(() => _voiceDisabledReason = result.message);
      debugPrint('mic preflight failed: ${result.rawName}');
      return;
    }
    _startVoiceSession();
  },
)

Degrade gracefully: "mic unavailable — keep typing"

A blocked mic should never dead-end the conversation. WidgetChat's voice chat and text chat are the same widget, the same conversation, and the same dashboard — so when the preflight fails, hide the mic affordance, surface the specific fix, and keep the SSE text stream running:

import 'dart:convert';
import 'package:http/http.dart' as http;

Stream<String> streamReply(http.Client client, String message, String sessionId) async* {
  final request = http.Request(
    'POST',
    Uri.parse('https://api.widgetchat.app/v1/chat/stream'),
  )
    ..headers.addAll({
      'Authorization': 'Bearer $widgetChatApiKey',
      'Content-Type': 'application/json',
      'Accept': 'text/event-stream',
    })
    // Copy the exact field names from your project's install snippet.
    ..body = jsonEncode({'message': message, 'session_id': sessionId});

  final response = await client.send(request);
  final lines = response.stream
      .transform(utf8.decoder)
      .transform(const LineSplitter());

  await for (final line in lines) {
    if (!line.startsWith('data:')) continue;
    final chunk = line.substring(5).trim();
    if (chunk.isEmpty || chunk == '[DONE]') continue;
    yield chunk;
  }
}

One Flutter Web trap here too: package:http's default BrowserClient buffers the entire response, so your token-by-token stream arrives as one lump. Inject FetchClient from package:fetch_client (a drop-in http.Client built on the Fetch API, with Wasm support) behind a conditional import to get genuinely incremental SSE on web.

Ship checklist

  • Served over https:// in production — not an IP, not plain http://
  • If embedded: allow="microphone" on the iframe and the host's Permissions-Policy naming your origin
  • Your own host's Permissions-Policy isn't blocking microphone
  • getUserMedia called directly in the tap handler, before other awaits
  • DOMException names mapped to specific copy, not one generic "permission denied"
  • Text stream still reachable when the mic isn't
  • Voice enabled for the project in the dashboard's Voice section, and voice minutes remaining on your plan

Once the browser side is clean, the rest is already handled: tap the mic and users get a real-time, speech-to-speech call inside the same widget — it replies out loud in a natural voice, supports barge-in so users can interrupt mid-sentence, shows live captions, and can put product cards on screen while it speaks. Provider API keys stay server-side, never in your web bundle. Voice name, max session length, and captions default are all configurable per project under Voice in the dashboard.

Try WidgetChat free and get a voice-and-text AI support assistant running in your Flutter or FlutterFlow app today.

MDN's Permissions-Policy: microphone reference — the directive that decides whether your embedded Flutter app can ever prompt.

The full list of DOMException names getUserMedia can reject with, which drives the decision tree above.

permission_handler on pub.dev — web support ships via permission_handler_html, but it collapses every failure into permanentlyDenied.

Author

About the author

Widget Chat is a team of developers and designers passionate about creating the best AI chatbot experience for Flutter, web, and mobile apps.

Comments

Comments are coming soon. We'd love to hear your thoughts!