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://localcapacitor://localhostionic://localhosttauri://localhostfile://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; Securewhen 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:
- Preflight request
- 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
nullunless 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.