Custom schemes are where a lot of clean CORS theory goes to die.

On the web, most teams think in terms of https://app.example.com calling https://api.example.com. Then product ships a desktop app, a mobile WebView, or an Electron wrapper, and suddenly requests come from stuff like:

  • myapp://local
  • capacitor://localhost
  • ionic://localhost
  • tauri://localhost
  • file://
  • null

That’s when the usual “just set Access-Control-Allow-Origin” advice stops being enough.

I’ve seen this play out on a desktop app rollout where the API worked perfectly in browsers, then failed in production for the packaged app. Same frontend code, same backend, same auth flow. The only difference was the request origin.

The setup

The team had:

  • A web app at https://dashboard.acme.test
  • An API at https://api.acme.test
  • A new desktop app built with a WebView using a custom scheme: acme://app

The browser version worked. The desktop app didn’t.

Frontend error:

fetch("https://api.acme.test/v1/me", {
  credentials: "include"
})

Console output:

Access to fetch at 'https://api.acme.test/v1/me' from origin 'acme://app'
has been blocked by CORS policy:
The 'Access-Control-Allow-Origin' header has a value 'https://dashboard.acme.test'
that is not equal to the supplied origin.

Pretty standard so far. The backend had a narrow allowlist.

Before: browser-only CORS config

Their Express middleware looked like this:

const allowedOrigins = [
  "https://dashboard.acme.test",
  "https://admin.acme.test"
];

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

  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-Headers", "Content-Type, Authorization");
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
  }

  if (req.method === "OPTIONS") {
    return res.sendStatus(204);
  }

  next();
});

That config was fine for normal web origins. It failed for the desktop app because acme://app wasn’t allowed.

The first instinct was predictable: “Can we just use *?”

Nope, not with credentials.

If your frontend sends cookies or HTTP auth, this combination is invalid:

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

Browsers reject it.

That’s why public APIs often use * only when credentials aren’t involved. GitHub is a good real-world example. api.github.com returns:

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

That works because GitHub’s CORS model is designed for broad public access, not cookie-based app sessions from arbitrary origins.

The second mistake: reflecting every origin

The team’s quick fix was worse:

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

  if (origin) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
  }

  if (req.method === "OPTIONS") {
    return res.sendStatus(204);
  }

  next();
});

This “works” in testing because every origin gets echoed back.

It also means any site can make credentialed cross-origin requests if the browser sends cookies. That’s not just sloppy — it’s a real security bug. CORS is not auth, but a bad CORS policy can absolutely widen the blast radius of a CSRF or token leakage issue.

And once custom schemes enter the picture, teams get tempted to allow weird things like null, file://, or any non-HTTP origin just to make the app function again.

That’s how bad CORS policies are born.

What made custom schemes tricky

There were three practical problems.

1. The origin string was valid but unfamiliar

The backend saw:

Origin: acme://app

A lot of homegrown validators only expected http:// and https://. Some even used regex like this:

/^https?:\/\/[a-z0-9.-]+$/i

That silently rejected custom schemes.

2. Different app runtimes behaved differently

During development, requests came from:

http://localhost:3000

In production desktop builds, they came from:

acme://app

On some mobile builds, they came from:

capacitor://localhost

A single hardcoded allowlist for browser origins wasn’t enough.

3. Preflight handling was incomplete

The app sent JSON plus an Authorization header, so browsers triggered preflight:

OPTIONS /v1/me
Origin: acme://app
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorization, content-type

The server only responded correctly for known browser origins, so preflight failed before the real request even happened.

When I troubleshoot this stuff, I usually verify the exact headers first with a tool instead of guessing. Something like HeaderTest saves time because you can see what your API actually emits for preflight and real requests.

After: explicit support for trusted custom schemes

The fix was boring, which is usually a good sign.

They moved to a strict allowlist that included trusted custom-scheme origins used by shipped apps.

const allowedOrigins = new Set([
  "https://dashboard.acme.test",
  "https://admin.acme.test",
  "acme://app",
  "capacitor://localhost"
]);

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-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With");
    res.setHeader("Access-Control-Max-Age", "600");
    res.setHeader("Access-Control-Expose-Headers", "ETag, Link, Retry-After");
  }

  if (req.method === "OPTIONS") {
    return res.sendStatus(origin && allowedOrigins.has(origin) ? 204 : 403);
  }

  next();
});

That solved the immediate issue without opening the API to every origin on earth.

A few details mattered.

Why this version held up in production

Explicit allowlisting

They allowed exact origin strings, not patterns like “anything with scheme acme”.

Good:

allowedOrigins.has(origin)

Risky:

origin.startsWith("acme://")

Custom schemes are not magic trust boundaries. If your platform lets other apps register similar schemes or your validation is too loose, you can accidentally trust origins you never intended.

Vary: Origin

This gets skipped all the time.

If your CDN or reverse proxy caches a response with:

Access-Control-Allow-Origin: acme://app

and serves it to a different origin later, you get broken behavior and occasionally weird security side effects. Vary: Origin tells caches that origin-specific responses are actually different variants.

Tight preflight responses

The server answered OPTIONS only for allowed origins. That matters. A permissive preflight handler can make debugging easier in the short term and policy enforcement meaningless in the long term.

No null origin shortcut

Some developers add this after seeing WebView issues:

allowedOrigins.add("null");

Usually a mistake.

null can come from sandboxed iframes, file: contexts, data URLs, and other edge cases you probably do not want to trust broadly. If your app genuinely uses a null origin, stop and verify the runtime behavior first. Most teams are better off switching to a stable custom scheme or localhost-based origin instead.

The auth side mattered too

There was one more problem: the desktop app originally used cookie-based session auth.

That works, but it makes CORS stricter and more fragile because you need:

  • exact origin matching
  • Access-Control-Allow-Credentials: true
  • compatible cookie settings like SameSite=None; Secure when needed

They eventually moved the desktop app to bearer tokens stored in the app layer rather than browser cookies. That reduced the number of moving parts. Not always possible, but often cleaner for non-browser runtimes.

If you go down that route, remember CORS still applies to Authorization headers. You just don’t have the wildcard-plus-credentials restriction anymore.

A practical test matrix

Once they fixed the config, they tested these cases explicitly:

Allowed

Origin: https://dashboard.acme.test
Origin: acme://app
Origin: capacitor://localhost

Denied

Origin: https://evil.example
Origin: null
Origin: file://

And they tested both:

  1. Preflight request
  2. Actual request with auth

That second part matters because teams often validate only the final GET or POST and forget that the browser may never send it if OPTIONS fails.

What I’d do from day one

If I know a product will ship in browser, desktop, and mobile wrappers, I design the CORS policy around real runtime origins before launch.

Not “web now, app later.”

Something like this:

  • inventory every actual origin per platform
  • decide whether credentials are required
  • use exact allowlists
  • reject null unless there’s a very specific, reviewed reason
  • expose only headers the client needs
  • test preflight as a first-class flow

And if you’re tightening broader response header policy too, pair CORS work with the rest of your security headers. If you need a refresher on that side, csp-guide.com is useful.

The big lesson from this case wasn’t that custom schemes are special. It was that they force you to stop pretending CORS is only for websites.

Once your client is a desktop shell or a mobile WebView, the browser security model is still there — just wearing different clothes. If your API only trusts https:// origins from a traditional browser, your app rollout is going to be painful.

The fix is simple: trust the exact origins you own, including custom schemes, and nothing else. That’s the version that survives contact with production.