Mobile developers get told weird things about CORS.
I’ve heard all of these:
- “Mobile apps don’t use CORS.”
- “Just set
Access-Control-Allow-Origin: *and move on.” - “CORS is only a frontend problem.”
- “If the API is private, CORS doesn’t matter.”
Some of that is half true, which is usually worse than being completely wrong.
If you’re building a backend for iOS or Android, you need to understand when CORS applies, when it doesn’t, and why your support queue suddenly fills up the moment someone adds a webview, an admin dashboard, or a docs playground running in the browser.
First: native mobile apps usually do not enforce CORS
A native app using URLSession, OkHttp, Retrofit, Alamofire, or similar HTTP clients is generally not subject to browser CORS enforcement.
That means this request from an iPhone app:
let url = URL(string: "https://api.example.com/v1/profile")!
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
URLSession.shared.dataTask(with: request) { data, response, error in
// Handle response
}.resume()
and this request from Android:
val request = Request.Builder()
.url("https://api.example.com/v1/profile")
.addHeader("Authorization", "Bearer $token")
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {}
override fun onResponse(call: Call, response: Response) {
val body = response.body?.string()
}
})
will work regardless of whether your server sends Access-Control-Allow-Origin.
That’s the first big mental model: CORS is a browser security policy, not a general HTTP security feature.
So why should mobile backend teams care?
Because mobile backends almost never stay mobile-only.
The same API usually ends up being used by:
- a React admin panel
- a customer web dashboard
- a support tool
- API docs with “try it” buttons
- an embedded webview inside the app
- OAuth or SSO flows that bounce through the browser
The day a browser touches your API, CORS becomes real.
And if you don’t plan for it early, you get the classic “works in the app, fails on web” bug report.
Where mobile teams get tripped up
1. Webviews behave like browsers
If your app loads a webview that calls your API with JavaScript, CORS applies.
Example:
fetch("https://api.example.com/v1/profile", {
headers: {
Authorization: `Bearer ${token}`
}
})
```text
If that code runs inside a `WKWebView` or Android `WebView`, the browser engine may enforce CORS just like Safari or Chrome would.
This surprises teams because they think “it’s inside our app, so it’s native.” It isn’t. If the request comes from browser JavaScript, CORS rules matter.
### 2. `Authorization` triggers preflight
Most authenticated API calls from browser-based clients send headers like:
- `Authorization`
- `Content-Type: application/json`
That often triggers a preflight `OPTIONS` request.
Browser sends:
OPTIONS /v1/profile HTTP/1.1 Origin: https://app.example.com Access-Control-Request-Method: GET Access-Control-Request-Headers: authorization
Server must answer correctly:
HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Allow-Headers: Authorization, Content-Type Access-Control-Max-Age: 600
If your server ignores `OPTIONS`, your browser client fails before the real request is even sent.
Native mobile clients won’t care. Browser clients absolutely will.
### 3. Teams confuse CORS with auth
CORS does **not** protect your API from unauthorized access.
If your API is public on the internet, anyone can call it from curl, a mobile app, a script, or a server, whether CORS allows browser access or not.
This is allowed by CORS:
Access-Control-Allow-Origin: https://app.example.com
But it does not mean only `app.example.com` can access the API. It only means browser JavaScript running on that origin can read the response.
Your actual protection still comes from:
- bearer tokens
- session validation
- API keys
- mTLS
- signed requests
- rate limiting
I’m blunt about this because I’ve seen people ship “secure” APIs with weak auth because they thought strict CORS was enough. It isn’t.
## A real-world header example
GitHub’s API is a good example of a public API making deliberate CORS choices. `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
Two useful takeaways:
1. `Access-Control-Allow-Origin: *` makes sense for a public API.
2. `Access-Control-Expose-Headers` is how browser code can read useful non-simple response headers like rate limit metadata.
Without `Access-Control-Expose-Headers`, your frontend can receive the response body but still be unable to read headers you care about.
Example:
```javascript
const res = await fetch("https://api.github.com/rate_limit");
console.log(res.headers.get("X-RateLimit-Remaining")); // works only if exposed
If you’re building a mobile backend that also powers a web client, exposing pagination, rate-limit, or request-id headers is often worth doing.
A sane Express setup
Here’s an Express API configuration I’d actually ship for a backend used by native apps and one browser frontend.
const express = require("express");
const cors = require("cors");
const app = express();
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com"
]);
app.use(cors({
origin(origin, callback) {
// Allow non-browser requests 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 by CORS"));
},
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Authorization", "Content-Type", "X-Request-Id"],
exposedHeaders: ["ETag", "Link", "X-RateLimit-Remaining", "X-Request-Id"],
credentials: false,
maxAge: 600
}));
app.options("*", cors());
app.get("/v1/profile", (req, res) => {
res.set("X-Request-Id", "req_123");
res.set("X-RateLimit-Remaining", "42");
res.json({ id: 1, name: "Ava" });
});
app.listen(3000);
```text
A few opinions here:
- I prefer explicit origin allowlists for private backends.
- I allow requests with no `Origin` header because native apps, curl, and server-to-server traffic commonly have none.
- I expose only headers clients actually need.
- I keep `credentials: false` unless I’m truly using cookies in the browser.
## Cookie-based auth changes the rules
If your browser client uses cookies, you cannot combine credentials with wildcard origin.
This is invalid for credentialed CORS:
Access-Control-Allow-Origin: * Access-Control-Allow-Credentials: true
You need a specific origin:
Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Credentials: true Vary: Origin
That `Vary: Origin` part matters for caches and CDNs. Skip it and you can serve the wrong CORS headers to the wrong origin.
If your mobile app uses bearer tokens and your web app also uses bearer tokens, life is simpler. Cookie auth in browser APIs is where CORS configs get messy fast.
## Nginx example for preflight handling
A lot of teams terminate traffic at Nginx and then wonder why `OPTIONS` breaks. Here’s a minimal pattern:
server { listen 443 ssl; server_name api.example.com;
location / {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin https://app.example.com always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Request-Id" always;
add_header Access-Control-Max-Age 600 always;
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
add_header Access-Control-Allow-Origin https://app.example.com always;
add_header Access-Control-Expose-Headers "ETag, Link, X-RateLimit-Remaining, X-Request-Id" always;
proxy_pass http://backend_upstream;
}
}
If you support multiple origins, don’t hardcode the wrong one everywhere. Reflecting origin dynamically can work, but only if you validate it against an allowlist first.
## Testing CORS without guessing
Don’t debug CORS by staring at app code and hoping.
Use:
- browser devtools network tab
- curl for raw headers
- a header inspection tool like [headertest.com](https://headertest.com)
Quick curl examples:
curl -i https://api.example.com/v1/profile
-H “Origin: https://app.example.com”
Preflight simulation:
curl -i -X OPTIONS https://api.example.com/v1/profile
-H “Origin: https://app.example.com”
-H “Access-Control-Request-Method: GET”
-H “Access-Control-Request-Headers: authorization,content-type”
That usually tells you exactly what’s broken in under a minute.
## Security headers around CORS
CORS is only one layer. If your backend also serves web content, look at the rest of your security headers too: CSP, HSTS, `X-Content-Type-Options`, and friends. If you need a reference for that side of things, [csp-guide.com](https://csp-guide.com) is useful.
But keep the boundaries clear:
- **CORS** controls which browser origins can read responses
- **CSP** controls what a page is allowed to load and execute
- **Auth** controls who can access data
- **TLS** protects transport
Different tools, different jobs.
## Rules I’d give any mobile backend team
1. Assume your API will eventually be called from a browser.
2. Don’t use CORS as a substitute for authentication.
3. Allow requests with no `Origin` header for native/server clients.
4. Handle `OPTIONS` cleanly.
5. Expose response headers your browser client genuinely needs.
6. Use explicit origins for private apps.
7. Use `*` only when the API is truly public and you mean it.
8. Be extra careful with cookies and `Access-Control-Allow-Credentials`.
If your backend is strictly native-only today, CORS might not matter yet. But “native-only” rarely lasts. Planning for browser access early saves a lot of ugly rewrites later.