Start Debugging

How to block a Flutter WebView from navigating to external URLs with NavigationDelegate

Keep a Flutter WebView on your own domain with webview_flutter 4.14.1: parse the URL, compare Uri.host instead of startsWith, hand mailto: and tel: to url_launcher, and know what Android and iOS actually send to onNavigationRequest.

Short answer: with webview_flutter 4.14.1 on Flutter 3.44, give the WebViewController a NavigationDelegate whose onNavigationRequest parses request.url with Uri.tryParse, returns NavigationDecision.navigate only when uri.scheme == 'https' and uri.host is on your allowlist, and returns NavigationDecision.prevent for everything else, optionally opening the blocked link in the system browser with url_launcher. Do not use url.startsWith('https://example.com'): it lets https://example.com.evil.net and https://example.com@evil.net through. And know the limits: on Android the callback never sees subframe navigations or POST form submissions.

The rest of this post builds that policy step by step, shows the test table that proves the naive check is wrong, and walks through the platform differences that decide what your callback can and cannot block. Everything below was compiled and tested against webview_flutter 4.14.1, webview_flutter_android 4.14.1, webview_flutter_wkwebview 3.26.1 and url_launcher 6.3.2 on Flutter 3.44.8 / Dart 3.12.2.

Why a WebView wanders off your site

An embedded help center, a checkout page, or a terms-of-service screen is usually meant to show one site. The page does not know that. It contains footer links to Twitter, a “powered by” badge, an OAuth button, a mailto: support address, and maybe user-generated content with arbitrary links. Tap any of those and the WebView happily loads it inside your app, with no address bar, no back button unless you built one, and your app’s name at the top of the screen. That is a UX problem (users get stranded on a third-party site) and a trust problem (a phishing page rendered inside your app inherits your app’s credibility).

webview_flutter exposes one hook for this: NavigationDelegate.onNavigationRequest. Its signature in 4.14.1 is:

// webview_flutter 4.14.1
FutureOr<NavigationDecision> Function(NavigationRequest request)? onNavigationRequest

NavigationRequest carries exactly two fields, url (a String) and isMainFrame (a bool), and NavigationDecision has two values, navigate and prevent. Everything else is up to you.

The README example is the bug

The official package README shows this snippet:

// From the webview_flutter 4.14.1 README
onNavigationRequest: (NavigationRequest request) {
  if (request.url.startsWith('https://www.youtube.com/')) {
    return NavigationDecision.prevent;
  }
  return NavigationDecision.navigate;
},

As a denylist demo that is fine. Flip it into an allowlist, which is what most people do, and you get if (request.url.startsWith('https://example.com')) navigate else prevent. String prefixes are not how URLs work. I ran 16 URLs through both the naive prefix check and the policy class built below:

URL                                                  naive     policy
https://example.com/pricing                          internal  allowInWebView
https://help.example.com/articles/42                 external  allowInWebView
https://EXAMPLE.com/Pricing                          external  allowInWebView
https://example.com:8443/admin                       internal  allowInWebView
https://example.com.evil.net/login                   internal  openExternally
https://example.com@evil.net/login                   internal  openExternally
https://notexample.com/                              external  openExternally
https://evil.net/?next=https://example.com           external  openExternally
http://example.com/                                  external  openExternally
mailto:support@example.com                           external  openExternally
tel:+15550100                                        external  openExternally
about:blank                                          external  allowInWebView
about:srcdoc                                         external  block
javascript:alert(1)                                  external  block
intent://scan/#Intent;scheme=zxing;end               external  block
file:///data/data/com.example/shared_prefs/x.xml     external  block

Two rows are the dangerous ones. https://example.com.evil.net/login is a host owned by whoever registered evil.net. https://example.com@evil.net/login puts example.com in the userinfo part of the URL, so the browser connects to evil.net. Both pass the prefix check and render inside your app. The other disagreements are false negatives: an uppercase host or a subdomain gets kicked out of the WebView for no reason.

The fix is to let Uri do the parsing. Uri.parse('https://EXAMPLE.com@evil.net:8443/x').host returns evil.net: lower case, userinfo and port stripped. Compare that, never the raw string.

Building the allowlist policy

Keep the decision in a plain Dart class with no Flutter or plugin imports. That makes it unit-testable with flutter test, which matters because you cannot run a real WebView in a widget test.

// Flutter 3.44, Dart 3.12, webview_flutter 4.14.1
enum LinkAction { allowInWebView, openExternally, block }

class LinkPolicy {
  const LinkPolicy({
    required this.allowedHosts,
    this.allowSubdomains = true,
    this.externalSchemes = const {'mailto', 'tel', 'sms'},
  });

  /// Hosts that may load inside the WebView, lower case, no scheme, no port.
  final Set<String> allowedHosts;
  final bool allowSubdomains;

  /// Schemes handed to the OS instead of the WebView.
  final Set<String> externalSchemes;

  LinkAction decide(String url) {
    final uri = Uri.tryParse(url);
    if (uri == null) return LinkAction.block;

    switch (uri.scheme) {
      case 'https':
        return _isAllowedHost(uri.host)
            ? LinkAction.allowInWebView
            : LinkAction.openExternally;
      case 'http':
        // Never load cleartext in-app, even for your own host.
        return LinkAction.openExternally;
      case 'about':
        return url == 'about:blank'
            ? LinkAction.allowInWebView
            : LinkAction.block;
      default:
        return externalSchemes.contains(uri.scheme)
            ? LinkAction.openExternally
            : LinkAction.block; // javascript:, file:, intent:, data:, ...
    }
  }

  bool _isAllowedHost(String host) {
    // Uri.host is already lower case and has userinfo and port stripped.
    if (allowedHosts.contains(host)) return true;
    if (!allowSubdomains) return false;
    return allowedHosts.any((allowed) => host.endsWith('.$allowed'));
  }
}

A few choices here are deliberate:

The table above is the output of this test file, which runs in about a second with flutter test:

// Flutter 3.44, Dart 3.12
import 'package:flutter_test/flutter_test.dart';
import 'package:wvguard/link_policy.dart';

void main() {
  const policy = LinkPolicy(allowedHosts: {'example.com'});

  final cases = <String, LinkAction>{
    'https://help.example.com/articles/42': LinkAction.allowInWebView,
    'https://EXAMPLE.com/Pricing': LinkAction.allowInWebView,
    'https://example.com.evil.net/login': LinkAction.openExternally,
    'https://example.com@evil.net/login': LinkAction.openExternally,
    'https://notexample.com/': LinkAction.openExternally,
    'http://example.com/': LinkAction.openExternally,
    'mailto:support@example.com': LinkAction.openExternally,
    'javascript:alert(1)': LinkAction.block,
    'intent://scan/#Intent;scheme=zxing;end': LinkAction.block,
  };

  for (final entry in cases.entries) {
    test(entry.key, () => expect(policy.decide(entry.key), entry.value));
  }
}

Wiring the policy into the NavigationDelegate

The steps, in order:

  1. Add the packages: flutter pub add webview_flutter url_launcher. webview_flutter 4.14.1 needs Flutter 3.38 or later, Android SDK 24+ and iOS 13+.
  2. Create the WebViewController once, in initState, not in build.
  3. Call setNavigationDelegate with an onNavigationRequest that maps each LinkAction to a NavigationDecision.
  4. For openExternally, fire launchUrl with LaunchMode.externalApplication and return prevent immediately.
  5. Call loadRequest last, after the delegate is in place.
// Flutter 3.44, Dart 3.12, webview_flutter 4.14.1, url_launcher 6.3.2
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart';

import 'link_policy.dart';

class HelpCenterPage extends StatefulWidget {
  const HelpCenterPage({super.key});

  @override
  State<HelpCenterPage> createState() => _HelpCenterPageState();
}

class _HelpCenterPageState extends State<HelpCenterPage> {
  static final Uri _home = Uri.parse('https://help.example.com/');
  static const LinkPolicy _policy = LinkPolicy(allowedHosts: {'example.com'});

  late final WebViewController _controller;

  @override
  void initState() {
    super.initState();
    _controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setNavigationDelegate(
        NavigationDelegate(onNavigationRequest: _onNavigationRequest),
      )
      ..loadRequest(_home);
  }

  NavigationDecision _onNavigationRequest(NavigationRequest request) {
    // Android never asks about subframes; iOS does. Leave iframes alone here.
    if (!request.isMainFrame) return NavigationDecision.navigate;

    switch (_policy.decide(request.url)) {
      case LinkAction.allowInWebView:
        return NavigationDecision.navigate;
      case LinkAction.openExternally:
        unawaited(_openExternally(Uri.parse(request.url)));
        return NavigationDecision.prevent;
      case LinkAction.block:
        debugPrint('Blocked navigation to ${request.url}');
        return NavigationDecision.prevent;
    }
  }

  Future<void> _openExternally(Uri uri) async {
    final opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
    if (!opened && mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('No app can open $uri')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Help')),
      body: WebViewWidget(controller: _controller),
    );
  }
}

flutter analyze reports no issues on this file. Two details are worth calling out.

The callback returns synchronously. onNavigationRequest accepts a Future<NavigationDecision>, but on iOS the plugin awaits your callback inside WebKit’s decidePolicyForNavigationAction, so every millisecond you spend there is a millisecond the page sits frozen. Awaiting launchUrl (which waits for the OS to switch apps) is exactly the wrong thing to do there. Decide synchronously, return, and launch in the background with unawaited.

The mounted check after await launchUrl is there because the user may have popped the page by the time the OS answers. If that pattern is new to you, I covered it in detail in using BuildContext safely after an await.

What Android and iOS actually send to onNavigationRequest

This is the part the docs gloss over with “some platforms may also trigger this callback from calls to loadRequest”. I read the platform implementations in webview_flutter_android 4.14.1 and webview_flutter_wkwebview 3.26.1, and the two behave very differently.

Android: the native side cancels first, Dart re-issues

On Android the callback is driven by WebViewClient.shouldOverrideUrlLoading. When you set onNavigationRequest, the plugin calls setSynchronousReturnValueForShouldOverrideUrlLoading(true). From then on the native WebViewClientProxyApi returns request.isForMainFrame() && true for every navigation: every main-frame navigation is cancelled immediately, before Dart has even been asked. Dart then runs your callback, and if it returns navigate, the plugin calls loadUrl with the same URL and the original request headers.

Consequences:

iOS and macOS: WebKit waits for your answer

On WebKit the callback is driven by WKNavigationDelegate.webView(_:decidePolicyFor:decisionHandler:). The plugin awaits your callback and maps navigate to .allow and prevent to .cancel. Nothing is re-issued, so POST bodies and headers survive intact.

Consequences:

If you need consistent iframe policing on both platforms, the navigation delegate is the wrong tool. Send a Content-Security-Policy header with frame-src from your own server, which both WebViews enforce.

Gotchas that let traffic slip past the allowlist

The navigation delegate controls page navigations. It does not see:

Three more that bite in practice:

If your WebView is showing your own Flutter web build rather than a regular site, the in-app routing lives in your router, not the WebView, and nested routes and deep links with go_router is the more relevant piece. Font scaling inside that embedded Flutter web build is a separate trap I wrote up in Flutter Text rendering off-screen in an Android WebView.

Async decisions behave differently per platform

Because Android cancels first and re-issues later, an async callback on Android never blocks the page: the old page stays on screen, fully interactive, until your Future completes and the plugin calls loadUrl. If the user taps a second link in the meantime, both decisions run and whichever loadUrl runs last wins. On iOS the same async callback holds WebKit’s decision handler open, so the page waits. If your policy genuinely needs I/O (for example, fetching a remote allowlist), load it once before the page opens and keep onNavigationRequest synchronous, as in the example above. That gives identical behaviour on both platforms.

If you need control the cross-platform API does not expose, such as intercepting subresource requests with shouldInterceptRequest, there is no Dart hook for it in 4.14.1. That means native code, and the approach from adding platform-specific code in Flutter without plugins applies. Try the allowlist first; it is enough for the vast majority of embedded pages.

Finally, remember that anything you ship in the app binary, including the allowlist, is readable by anyone who unpacks the APK or IPA. That is fine for a list of hostnames, but it is a good reminder of what an attacker can extract from a Flutter app: the allowlist protects your users from wandering off, it is not a secret.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back