Teams hit a weird wall with React Native WebView all the time: the same API call works fine in native code, then suddenly fails when it runs inside a WebView. People call it “a React Native bug” or “an Android thing.” Most of the time, it’s just CORS doing exactly what the browser engine inside the WebView is supposed to do.

I’ve seen this happen in hybrid apps that embed a React checkout flow, an admin dashboard, or a support portal. The native shell works. The web app inside the shell blows up with “Network request failed,” “Origin null is not allowed,” or a preflight that never gets approved.

Here’s a case study that mirrors a real production cleanup.

The setup

A team had a React Native app with an embedded support console inside react-native-webview. The console was hosted on https://support.example.com and made API calls to https://api.example.com.

On desktop Chrome, everything worked.

Inside the React Native WebView:

  • GET /me failed intermittently
  • POST /tickets always failed
  • image loads worked
  • direct navigation to the API endpoint worked
  • fetch() from the embedded app failed

That pattern is classic CORS.

Why WebView changes the story

A React Native app making requests through native modules does not go through browser CORS enforcement. But code running inside a WebView is browser code. On iOS that usually means WKWebView behavior. On Android it’s Chromium-based WebView behavior.

So this works:

import React, { useEffect } from 'react';
import { View } from 'react-native';

export default function NativeScreen() {
  useEffect(() => {
    fetch('https://api.example.com/me', {
      headers: {
        Authorization: 'Bearer token123',
      },
    })
      .then(r => r.json())
      .then(console.log)
      .catch(console.error);
  }, []);

  return <View />;
}

But this embedded page can fail because the browser engine enforces CORS:

import React from 'react';
import { WebView } from 'react-native-webview';

export default function SupportWebView() {
  return (
    <WebView source={{ uri: 'https://support.example.com/app' }} />
  );
}

And inside https://support.example.com/app:

fetch('https://api.example.com/me', {
  headers: {
    Authorization: 'Bearer token123',
  },
  credentials: 'include',
});

If the API does not explicitly allow https://support.example.com, the browser blocks it.

The broken backend

Here’s what the API server looked like before the fix. Express app, hand-rolled CORS, and just enough mistakes to make debugging miserable.

import express from 'express';

const app = express();

app.use(express.json());

app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
  next();
});

app.get('/me', (req, res) => {
  res.json({ id: 1, name: 'Ava' });
});

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

app.listen(3000);

This looks harmless. It isn’t.

What’s wrong here

1. Wildcard origin with credentials use case

The frontend used:

credentials: 'include'

That means the response cannot use:

Access-Control-Allow-Origin: *

When credentials are involved, the server must echo a specific allowed origin.

2. Missing Authorization in allowed headers

The frontend sent:

Authorization: Bearer ...

That triggers preflight. The server allowed only Content-Type, so the browser rejected the actual request before it even happened.

3. No explicit OPTIONS handling

Many frameworks can limp through this, but production setups behind gateways, CDNs, and WAFs often need clear handling for preflight requests.

4. No exposed custom response headers

The frontend wanted to read pagination and rate-limit headers. Without Access-Control-Expose-Headers, browser JavaScript can’t read most non-simple response headers.

That last part gets overlooked a lot. GitHub handles this well. A real response from api.github.com includes:

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’s a good real-world example of exposing headers intentionally instead of hoping the browser makes them available.

What the failure looked like

The WebView app made this request:

fetch('https://api.example.com/tickets', {
  method: 'POST',
  credentials: 'include',
  headers: {
    'Content-Type': 'application/json',
    Authorization: 'Bearer token123',
  },
  body: JSON.stringify({ subject: 'Login issue' }),
});

The browser sent a preflight first:

OPTIONS /tickets
Origin: https://support.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-type

The server replied with something like:

Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: Content-Type
Access-Control-Allow-Methods: GET, POST, OPTIONS

From the browser’s point of view:

  • credentials requested, but origin is wildcard
  • requested header authorization is not allowed

Blocked.

The fix

The team stopped trying to “make WebView ignore CORS” and fixed the API policy instead. That’s the right move almost every time.

After: explicit, environment-aware CORS

import express from 'express';

const app = express();

const allowedOrigins = new Set([
  'https://support.example.com',
  'https://staging-support.example.com',
]);

app.use(express.json());

app.use((req, res, next) => {
  const origin = req.headers.origin;

  if (origin && allowedOrigins.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader(
      'Access-Control-Allow-Headers',
      'Content-Type, Authorization, X-Requested-With'
    );
    res.setHeader(
      'Access-Control-Allow-Methods',
      'GET, POST, PUT, PATCH, DELETE, OPTIONS'
    );
    res.setHeader(
      'Access-Control-Expose-Headers',
      'ETag, Link, Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining'
    );
    res.setHeader('Access-Control-Max-Age', '600');
  }

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

  next();
});

app.get('/me', (req, res) => {
  res.setHeader('ETag', '"user-123"');
  res.setHeader('X-RateLimit-Limit', '1000');
  res.setHeader('X-RateLimit-Remaining', '997');
  res.json({ id: 1, name: 'Ava' });
});

app.post('/tickets', (req, res) => {
  res.status(201).json({ ok: true, id: 't_123' });
});

app.listen(3000);

That fixed the WebView immediately.

Before and after on the frontend

Before

The frontend mixed cookie auth with bearer auth and didn’t know which one it actually needed:

fetch('https://api.example.com/me', {
  credentials: 'include',
  headers: {
    Authorization: 'Bearer token123',
  },
});

That’s not always wrong, but it complicates CORS and debugging.

After

They standardized on bearer tokens for the WebView app and dropped credentialed cross-origin requests:

fetch('https://api.example.com/me', {
  headers: {
    Authorization: 'Bearer token123',
  },
});

That let them simplify policy even further. If you don’t need cookies, don’t use credentials: 'include'. Life gets easier.

If they needed to read exposed headers:

const res = await fetch('https://api.example.com/me', {
  headers: {
    Authorization: 'Bearer token123',
  },
});

console.log(res.headers.get('ETag'));
console.log(res.headers.get('X-RateLimit-Remaining'));

Without Access-Control-Expose-Headers, those reads would return null in browser code even though the headers were present on the network response.

WebView-specific traps I keep seeing

Origin: null

If you load raw HTML into a WebView with source={{ html: ... }} or use file:// content, requests may come from a null origin. Many APIs block that, and they should.

Example:

<WebView
  source={{
    html: `<script>
      fetch('https://api.example.com/me').catch(console.error)
    </script>`,
  }}
/>

That often produces Origin: null.

If your app needs remote API access from a WebView, host the web content on a real HTTPS origin instead of injecting a mini app as a string.

Trying to solve CORS with client hacks

I’ve seen people try:

  • custom user agents
  • disabling web security in debug builds
  • proxying through random middleware
  • intercepting requests in WebView and replaying them natively

Those can hide the issue, but they usually create a bigger mess. If the code is running in a browser context, give it a proper CORS policy.

Missing Vary: Origin

If your API sits behind a CDN and you dynamically echo origins, Vary: Origin matters. Without it, one tenant’s allowed origin can get cached and served to another. That’s a nasty bug.

A safer architecture when CORS gets ugly

Sometimes the cleanest fix is not broader CORS. It’s reducing cross-origin calls.

For this team, the long-term improvement was serving the support app and API from the same site boundary in production:

  • https://support.example.com/app
  • https://support.example.com/api

That removed most CORS complexity for the WebView entirely.

If you’re already thinking about broader browser security around embedded apps, that’s where CSP and related headers enter the picture. For that side of the stack, https://csp-guide.com is useful. CORS controls who can read responses. CSP controls what the page itself is allowed to load and execute. Different tools, both worth getting right.

What I’d ship

If I were building this today for a React Native WebView, I’d keep the rules simple:

  1. Serve the embedded app from a real HTTPS origin.
  2. Avoid file:// and inline HTML for anything that talks to APIs.
  3. Use explicit allowlists for origins.
  4. Don’t use * if credentials are involved.
  5. Allow Authorization if you send bearer tokens.
  6. Expose only the response headers the frontend actually needs.
  7. Add Vary: Origin when origin reflection is dynamic.
  8. Prefer same-origin deployment when possible.

CORS in WebView isn’t special. That’s the trick. It feels special because you’re in a mobile app, but the browser engine doesn’t care. If your React code runs in a WebView, the API has to satisfy browser rules. Once the backend is honest about that, the “WebView bug” usually disappears fast.