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 /mefailed intermittentlyPOST /ticketsalways 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
authorizationis 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/apphttps://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:
- Serve the embedded app from a real HTTPS origin.
- Avoid
file://and inline HTML for anything that talks to APIs. - Use explicit allowlists for origins.
- Don’t use
*if credentials are involved. - Allow
Authorizationif you send bearer tokens. - Expose only the response headers the frontend actually needs.
- Add
Vary: Originwhen origin reflection is dynamic. - 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.