Capacitor sits in an awkward but very practical place: your app looks like a website, runs in a WebView, but ships like a native app. That hybrid setup changes how CORS behaves, and a lot of advice written for regular websites breaks down fast.
If you’ve ever thought:
- “My API works in Chrome but not in Capacitor”
- “Why is my app origin
capacitor://localhost?” - “Why do cookies disappear on mobile?”
- “Why does native HTTP magically bypass CORS?”
You’re in the right place.
The short version
CORS still matters in Capacitor when requests come from the WebView using fetch() or XMLHttpRequest. Your frontend code is still browser-like enough that the browser security model applies.
But Capacitor also gives you another path: native networking plugins. Those requests are not made by the browser engine, so CORS usually doesn’t apply the same way.
That split is where most confusion comes from.
How Capacitor changes the origin
A normal web app might run from:
https://app.example.com
A Capacitor app often runs from a custom or local origin inside the WebView, such as:
capacitor://localhost
http://localhost
ionic://localhost
That matters because the server sees the request’s Origin header and decides whether to allow it.
If your backend only allows:
Access-Control-Allow-Origin: https://app.example.com
then a Capacitor WebView origin like capacitor://localhost won’t match. The request gets blocked by CORS even though the app is “installed” on the phone.
A simple failing example
Frontend code in a Capacitor app:
const res = await fetch('https://api.example.com/user', {
method: 'GET',
headers: {
'Authorization': 'Bearer token123'
}
});
const data = await res.json();
console.log(data);
That Authorization header usually triggers a preflight request:
OPTIONS /user HTTP/1.1
Origin: capacitor://localhost
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorization
If the backend doesn’t answer with something like this:
Access-Control-Allow-Origin: capacitor://localhost
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
the WebView blocks the request before your app ever sees the response.
What a correct backend response looks like
For a Capacitor app using WebView fetch(), a server might return:
Access-Control-Allow-Origin: capacitor://localhost
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Vary: Origin
A few opinions here:
- If you use cookies or auth sessions, don’t use
*. - If you reflect origins dynamically, always send
Vary: Origin. - Don’t guess the mobile origin. Check what your app actually sends.
Real-world example: GitHub’s CORS headers
Some APIs are intentionally public and use wildcard CORS. api.github.com is a good example. Real response headers include:
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
This works well for public API access because any origin can read the response.
It also shows a detail developers miss all the time: Access-Control-Expose-Headers.
Without that header, your JavaScript can’t read many non-simple response headers even if the request itself succeeds.
Example:
const res = await fetch('https://api.github.com/repos/octocat/Hello-World');
console.log(res.headers.get('ETag')); // readable because it's exposed
console.log(res.headers.get('X-RateLimit-Remaining')); // also exposed
If your API returns useful custom headers for pagination, rate limits, or versioning, expose them explicitly.
Capacitor WebView requests vs native HTTP requests
This is the biggest architectural choice.
Option 1: Use fetch() in the WebView
Pros:
- Standard web APIs
- Shared code with your web app
- Cookies and browser behavior are more familiar
Cons:
- CORS applies
- Preflights apply
- WebView cookie behavior can be annoying
- Some mobile origin quirks show up
Option 2: Use Capacitor native HTTP
Pros:
- Usually bypasses browser CORS enforcement
- Better for some auth and networking edge cases
- Can avoid ugly WebView-origin allowlists
Cons:
- Different behavior from browser
fetch() - Cookie handling may differ
- Harder to share code cleanly
- You can accidentally sidestep useful browser protections
I’ve seen teams switch to native HTTP just to “fix CORS.” Sometimes that’s fine. Sometimes it hides a backend configuration problem that later breaks the web app anyway.
My advice: if the same API serves both web and mobile, fix CORS properly first. Use native HTTP when you genuinely need native networking behavior, not as your first panic button.
Backend configuration examples
Express
import express from 'express';
import cors from 'cors';
const app = express();
const allowedOrigins = new Set([
'https://app.example.com',
'capacitor://localhost',
'ionic://localhost',
'http://localhost'
]);
app.use(cors({
origin(origin, callback) {
// Allow non-browser tools with no Origin header
if (!origin) return callback(null, true);
if (allowedOrigins.has(origin)) {
return callback(null, true);
}
return callback(new Error(`Origin not allowed: ${origin}`));
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Authorization', 'Content-Type'],
exposedHeaders: ['ETag', 'Link', 'X-RateLimit-Remaining']
}));
app.get('/user', (req, res) => {
res.json({ id: 1, name: 'Ada' });
});
app.listen(3000);
This is boring, explicit, and good.
Nginx
map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
"capacitor://localhost" $http_origin;
"ionic://localhost" $http_origin;
"http://localhost" $http_origin;
}
server {
listen 443 ssl;
server_name api.example.com;
location / {
if ($cors_origin != "") {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
add_header Access-Control-Expose-Headers "ETag, Link, X-RateLimit-Remaining" always;
add_header Vary Origin always;
}
if ($request_method = OPTIONS) {
return 204;
}
proxy_pass http://backend;
}
}
Credentials, cookies, and why * fails
If your Capacitor app sends cookies or HTTP auth, your frontend might use:
await fetch('https://api.example.com/session', {
credentials: 'include'
});
That requires the server to return:
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: capacitor://localhost
Not this:
Access-Control-Allow-Origin: *
Wildcard origins and credentialed requests do not mix.
This is one of the most common production bugs in hybrid apps: the login endpoint seems fine, but session-based authenticated requests fail because the backend still uses *.
Preflight gotchas on mobile APIs
These things commonly trigger preflight:
AuthorizationContent-Type: application/jsonPUT,PATCH,DELETE- custom headers like
X-App-Version
That means your API must handle OPTIONS correctly.
Bad backend behavior:
- returns
404forOPTIONS - requires auth on
OPTIONS - redirects
OPTIONSto login - forgets CORS headers on error responses
That last one is especially nasty. If your app gets a 401, but the response lacks CORS headers, the frontend often sees a generic CORS failure instead of the actual auth error.
I always recommend adding CORS headers consistently, including on 4xx and 5xx responses.
Debugging what origin Capacitor actually sends
Don’t assume. Inspect.
Log the Origin header on your backend:
app.use((req, res, next) => {
console.log('Origin:', req.headers.origin);
next();
});
Then test your app on:
- iOS simulator
- Android emulator
- physical device
Origins can differ based on platform, configuration, and plugin setup.
A safe frontend wrapper
If you support both browser and Capacitor, keep your API access centralized.
type RequestOptions = {
method?: string;
token?: string;
body?: unknown;
};
export async function apiRequest(path: string, options: RequestOptions = {}) {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
if (options.token) {
headers['Authorization'] = `Bearer ${options.token}`;
}
const res = await fetch(`https://api.example.com${path}`, {
method: options.method ?? 'GET',
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
credentials: 'include'
});
if (!res.ok) {
const text = await res.text();
throw new Error(`API error ${res.status}: ${text}`);
}
return res.json();
}
When CORS breaks, having one request path makes debugging much less painful.
Security advice I’d actually enforce
A few rules I’m pretty strict about:
1. Don’t whitelist every origin just because mobile is annoying
This is how teams end up with:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
That’s invalid and sloppy.
2. Prefer explicit origin allowlists
For Capacitor, that often means allowing:
- your production web origin
capacitor://localhost- maybe
ionic://localhost - maybe
http://localhostfor dev
No more than necessary.
3. Expose only the headers your app needs
If your app reads ETag, Link, or rate-limit headers, expose those. Don’t dump every internal header into Access-Control-Expose-Headers.
4. Treat CORS as one layer, not your auth model
CORS is not authentication. It’s a browser read-control policy.
If you’re also reviewing related headers like CSP or framing protections for the web version of your app, the official docs are best, and for broader security-header guidance I sometimes point people to https://csp-guide.com.
When native HTTP is the right answer
I’d consider native HTTP in Capacitor when:
- the API is not intended for browser access at all
- CORS policy complexity is unreasonable for the architecture
- you need networking behavior that WebView
fetch()can’t provide reliably - cookie/session handling in the WebView is causing platform-specific pain
But if you go that route, document the behavior difference clearly. Your web app and mobile app are no longer exercising the same network stack.
That can surprise your backend team later.
Final checklist
If your Capacitor app has CORS issues, check these in order:
- What
Origindoes the app actually send? - Does the backend allow that exact origin?
- If using credentials, is
Access-Control-Allow-Originexplicit instead of*? - Does the backend handle
OPTIONSproperly? - Are
AuthorizationandContent-Typeallowed? - Are needed response headers listed in
Access-Control-Expose-Headers? - Are CORS headers present on error responses too?
- Are you using WebView
fetch()or native HTTP?
Most Capacitor CORS bugs come down to one boring truth: the app is still making browser-style requests from a nonstandard origin. Once you accept that, the fixes get a lot more predictable.
For Capacitor-specific behavior and configuration, stick to the official docs: https://capacitorjs.com/docs
And for the CORS spec behavior itself, the MDN and Fetch/CORS standards matter less than your real traffic. Log the headers, inspect preflights, and trust what the device is actually doing over what you expected it to do.