CORS in a Flutter WebView trips people up because there are really two different worlds hiding behind one app:

  1. Flutter web, where your app runs in a browser and CORS rules fully apply.
  2. Flutter mobile with a WebView, where browser-like behavior exists, but native networking and embedded browser behavior can change the story.

If you treat a WebView like “just Chrome inside Flutter,” you’ll misdiagnose bugs for hours. I’ve done that. The fix usually starts with one question:

Who is making the request?

  • JavaScript running inside the WebView page?
  • Dart code using http or dio on the native side?
  • The WebView itself loading a URL?
  • An iframe inside the page?

Each path has different CORS behavior.

The short version

CORS only matters for browser-enforced cross-origin requests. In a Flutter WebView, that usually means:

  • JavaScript fetch() or XMLHttpRequest inside the loaded page
  • subresource loads constrained by browser rules

CORS usually does not apply the same way when:

  • Dart native code makes the request using platform networking
  • the WebView navigates directly to a URL
  • your backend talks to the third-party API server-to-server

That distinction is the whole game.

What counts as cross-origin in a WebView

Origin is still the standard browser tuple:

  • scheme
  • host
  • port

So these are different origins:

  • https://app.example.com
  • https://api.example.com
  • http://app.example.com
  • https://app.example.com:8443

If your WebView loads https://app.example.com and JavaScript inside it calls https://api.example.com/data, that request is cross-origin and the server must opt in with CORS headers.

A real example from api.github.com:

access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning

That access-control-allow-origin: * tells the browser the response can be read by any origin, assuming credentials aren’t involved.

Flutter WebView is not Flutter web

This is the first thing I explain to teams.

Flutter web

Your Dart app compiles to JavaScript and runs in a browser. CORS is strict and unavoidable.

Flutter mobile WebView

Your app is native. The WebView hosts web content. JavaScript inside that content is subject to browser rules, but native Dart HTTP requests are not browser requests.

That means this Dart code in a Flutter mobile app:

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

Future<void> loadData() async {
  final res = await http.get(
    Uri.parse('https://api.example.com/data'),
    headers: {'Authorization': 'Bearer token'},
  );

  print(res.statusCode);
  print(res.body);
}

is not the same as this JavaScript running inside the WebView:

fetch('https://api.example.com/data', {
  headers: {
    Authorization: 'Bearer token'
  }
})
  .then(r => r.json())
  .then(console.log)
  .catch(console.error);
```text

The JS request can be blocked by CORS. The native Dart request usually won’t be.

## A basic Flutter WebView setup

Using `webview_flutter`, you’ll typically load a page and maybe enable JavaScript:

import ‘package:flutter/material.dart’; import ‘package:webview_flutter/webview_flutter.dart’;

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

@override State createState() => _MyWebViewPageState(); }

class _MyWebViewPageState extends State { late final WebViewController controller;

@override void initState() { super.initState();

controller = WebViewController()
  ..setJavaScriptMode(JavaScriptMode.unrestricted)
  ..loadRequest(Uri.parse('https://app.example.com'));

}

@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text(‘WebView’)), body: WebViewWidget(controller: controller), ); } }


If `https://app.example.com` runs JavaScript that fetches `https://api.example.com`, the API must return proper CORS headers.

## When preflight happens

A lot of “it works in Postman but not in WebView” bugs are really preflight failures.

A browser sends a preflight `OPTIONS` request when the real request is “non-simple,” for example:

- method is `PUT`, `PATCH`, `DELETE`
- custom headers like `Authorization`
- content type like `application/json` in some cases that trigger preflight behavior

Example JS inside the page:

```javascript
fetch('https://api.example.com/profile', {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer token'
  },
  body: JSON.stringify({ name: 'Taylor' })
});

The server may first receive:

OPTIONS /profile HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorization

The server must reply with something like:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600

If that response is missing or wrong, the browser blocks the real request before it even goes out.

A server example that works

Here’s a minimal Express server configured for a WebView-hosted frontend:

const express = require('express');
const app = express();

app.use(express.json());

app.use((req, res, next) => {
  const origin = req.headers.origin;
  const allowedOrigins = [
    'https://app.example.com',
    'https://staging-app.example.com',
  ];

  if (allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
    res.setHeader('Access-Control-Allow-Credentials', 'true');
  }

  res.setHeader(
    'Access-Control-Allow-Methods',
    'GET, POST, PUT, PATCH, DELETE, OPTIONS'
  );
  res.setHeader(
    'Access-Control-Allow-Headers',
    'Content-Type, Authorization, X-Requested-With'
  );
  res.setHeader(
    'Access-Control-Expose-Headers',
    'ETag, Link, X-RateLimit-Remaining'
  );

  if (req.method === 'OPTIONS') {
    return res.status(204).end();
  }

  next();
});

app.get('/data', (req, res) => {
  res.json({ ok: true });
});

app.listen(3000);
```text

A couple of opinionated notes:

- If you need cookies or HTTP auth, don’t use `*` for `Access-Control-Allow-Origin`.
- If you dynamically reflect origins, set `Vary: Origin`.
- Don’t blindly mirror every requested header. That gets sloppy fast.

## Credentials in WebView CORS

Credentials make CORS stricter.

If your page sends:

```javascript
fetch('https://api.example.com/me', {
  credentials: 'include'
});

then the server must return:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

This will not work:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Browsers reject that combination.

WebViews also add cookie complexity because cookie storage, SameSite rules, and platform behavior can differ from full browsers. If your auth depends on cross-site cookies, expect pain. Token-based auth is usually easier to reason about in embedded contexts.

Reading custom response headers

Even when the request succeeds, JavaScript can only read a limited set of response headers unless the server exposes them.

That’s where Access-Control-Expose-Headers matters.

GitHub is a good real-world example. It exposes useful headers like:

access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning

That lets frontend code do things like this:

const res = await fetch('https://api.github.com/rate_limit');
console.log(res.headers.get('x-ratelimit-remaining'));
console.log(res.headers.get('etag'));
```text

Without `Access-Control-Expose-Headers`, those values may exist on the wire but stay hidden from JavaScript.

## Common Flutter WebView CORS mistakes

### 1. Fixing the client instead of the server
Most CORS issues are server configuration issues. Toggling random WebView flags usually won’t fix a missing `Access-Control-Allow-Origin`.

### 2. Testing with native HTTP and assuming WebView JS will behave the same
Your Dart `http` call succeeds, but the page’s `fetch()` fails. That’s normal. Different enforcement model.

### 3. Using `*` with credentials
Doesn’t work. Use the exact origin.

### 4. Forgetting preflight support
If your API doesn’t handle `OPTIONS`, you’ll get blocked before your real request even starts.

### 5. Loading local HTML and calling remote APIs
If you use `loadHtmlString()` or local files, the origin may be `null` or a file-based origin. Some APIs reject that. This catches a lot of hybrid app teams.

## A practical workaround: move the request to native Dart

If you control the Flutter app, one reliable pattern is:

1. WebView page sends a message to Flutter
2. Flutter native code performs the HTTP request
3. Flutter injects the result back into the page

That avoids browser CORS enforcement for that request path.

Example bridge setup:

controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..addJavaScriptChannel( ‘NativeApi’, onMessageReceived: (message) async { final url = message.message;

  final res = await http.get(Uri.parse(url));
  final body = res.body.replaceAll(r'`', r'\`');

  await controller.runJavaScript(
    "window.onNativeApiResult(`$body`);",
  );
},

) ..loadHtmlString(’’’