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:

  • Authorization
  • Content-Type: application/json
  • PUT, PATCH, DELETE
  • custom headers like X-App-Version

That means your API must handle OPTIONS correctly.

Bad backend behavior:

  • returns 404 for OPTIONS
  • requires auth on OPTIONS
  • redirects OPTIONS to 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://localhost for 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:

  1. What Origin does the app actually send?
  2. Does the backend allow that exact origin?
  3. If using credentials, is Access-Control-Allow-Origin explicit instead of *?
  4. Does the backend handle OPTIONS properly?
  5. Are Authorization and Content-Type allowed?
  6. Are needed response headers listed in Access-Control-Expose-Headers?
  7. Are CORS headers present on error responses too?
  8. 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.