If you’ve ever opened an HTML file directly in the browser and watched fetch() explode with a CORS error, you’ve hit one of the weirdest corners of web security: file://.
I’ve seen this trip up experienced developers, not just beginners. The usual reaction is: “But I’m not even cross-origin. It’s just a local file.” The browser disagrees.
The core problem with file://
A page loaded from file:///Users/me/demo/index.html does not behave like a normal web app served from http://localhost. Browsers treat file:// as a special origin, and in many cases as an opaque origin or at least something heavily restricted. That means requests from a local file to:
https://api.example.comhttp://localhost:3000- even another local file
can trigger CORS or origin-related restrictions.
A lot of “CORS for file://” bugs are really “you’re not serving your app over HTTP” bugs.
Mistake #1: Testing frontend code by double-clicking index.html
This is the classic one.
You build a tiny demo:
<!doctype html>
<html>
<body>
<script>
fetch('https://api.github.com')
.then(r => r.json())
.then(console.log)
.catch(console.error);
</script>
</body>
</html>
Then you open it as:
file:///Users/me/demo/index.html
And the browser throws some version of:
Access to fetch at 'https://api.github.com/' from origin 'null' has been blocked by CORS policy
That origin 'null' is the giveaway. file:// pages often send Origin: null, which many servers do not explicitly allow.
Fix
Run a local HTTP server instead of opening files directly.
For example with Python:
python3 -m http.server 8080
Then visit:
http://localhost:8080
Now your page has a real origin: http://localhost:8080.
That one change fixes a shocking number of “CORS issues.”
Mistake #2: Assuming Access-Control-Allow-Origin: * always saves you
This is where people get confused, because some APIs do work from local files.
Take api.github.com. 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
That wildcard means cross-origin reads are broadly allowed. So yes, a simple unauthenticated request to GitHub’s API may work from a file:// page in some browsers and fail in others depending on browser handling, request type, and surrounding conditions.
The mistake is assuming this generalizes to every API.
A server that returns:
Access-Control-Allow-Origin: https://myapp.com
will not allow requests from file://, because file:// is not https://myapp.com.
A server that expects credentials is even stricter.
Fix
Don’t design around file:// behavior. Design around real web origins.
Use:
http://localhost:3000http://127.0.0.1:3000- your dev domain like
https://app.local.test
Then configure the API to allow that origin explicitly.
Example Express middleware:
app.use((req, res, next) => {
const allowedOrigins = [
'http://localhost:3000',
'http://127.0.0.1:3000'
];
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-Methods', 'GET,POST,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
That’s predictable. file:// is not.
Mistake #3: Trying to “fix CORS” in frontend JavaScript
I still see code like this:
fetch('https://api.example.com/data', {
headers: {
'Access-Control-Allow-Origin': '*'
}
});
This does nothing useful.
Access-Control-Allow-Origin is a response header. The browser checks whether the server sent it back. Sending it from the client is like filling out your own visa.
Same problem with browser-side hacks, extensions, or random Stack Overflow snippets that claim to disable CORS. They might hide the issue on your machine, but they do not fix your app.
Fix
Set CORS headers on the server, or put a backend in front of the API.
If you control the API, configure it correctly.
If you don’t control the API, proxy it through your own server:
app.get('/api/github', async (req, res) => {
const response = await fetch('https://api.github.com');
const data = await response.text();
res.setHeader('Content-Type', response.headers.get('content-type') || 'application/json');
res.send(data);
});
Then your frontend calls:
fetch('/api/github')
No browser cross-origin request, no browser CORS enforcement between your frontend and your backend.
Mistake #4: Using file:// with credentials or auth flows
This gets ugly fast.
Suppose you try:
fetch('https://api.example.com/private', {
credentials: 'include'
});
or you attach a bearer token:
fetch('https://api.example.com/private', {
headers: {
Authorization: 'Bearer token-here'
}
});
Now you’re likely triggering:
- stricter CORS checks
- preflight requests
- rejection of
Origin: null - broken cookie flows because cookies depend on real site context
Even when a public endpoint works from file://, authenticated endpoints often won’t.
Fix
Never test authenticated browser flows from file://.
Use a real local origin over HTTP or HTTPS. If cookies matter, get even closer to production:
- run the frontend on
http://localhost:3000 - run the API on
http://localhost:8000 - or use local HTTPS if SameSite / Secure behavior matters
For serious auth debugging, I want a setup that mirrors deployment, not a desktop file pretending to be an app.
Mistake #5: Forgetting about preflight
A simple GET may work. Then you add JSON or auth headers and suddenly everything breaks.
Example:
fetch('https://api.example.com/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer abc123'
},
body: JSON.stringify({ hello: 'world' })
});
That often triggers an OPTIONS preflight first.
If the server doesn’t answer with the right headers, the browser blocks the real request.
From a file:// origin, this can be even more fragile because the server may reject Origin: null before you ever get to the actual request.
Fix
Handle preflight explicitly.
Example:
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Vary: Origin
And return that on the OPTIONS request too.
If you’re debugging this in DevTools, inspect the preflight request first. The actual POST is often innocent; the OPTIONS response is where things are broken.
Mistake #6: Reading response headers that were never exposed
This one is subtle. Your request succeeds, but this fails:
const response = await fetch('https://api.github.com');
console.log(response.headers.get('ETag'));
Whether you can read ETag, Link, or rate-limit headers depends on Access-Control-Expose-Headers.
GitHub gets this right. Their real header includes:
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 means frontend code can read those headers.
A lot of APIs forget this and developers blame CORS broadly when the actual issue is header exposure.
Fix
Expose the headers your frontend needs:
Access-Control-Expose-Headers: ETag, Link, X-RateLimit-Remaining
Without that, the browser hides them even if the request itself succeeds.
Mistake #7: Disabling browser security for local testing
You can launch Chrome with flags that weaken security. You can install extensions that smash CORS checks. You can probably make your demo “work.”
Bad idea.
You’ll end up debugging a browser state your users will never have. Worse, you may normalize unsafe workflows on your team.
Fix
Use the boring setup:
- local dev server
- proper API CORS config
- proxy when needed
- production-like origins for auth and cookies
Boring is good here.
Practical rule of thumb
If your frontend is loaded with file://, assume you are outside the normal web security model and things may fail in inconsistent ways.
My rule is simple:
- Static HTML demo with no network calls?
file://is fine. - Anything using
fetch, auth, cookies, or APIs? Serve it over HTTP.
That’s the real fix for most file:// CORS problems.
A sane local setup
Frontend:
python3 -m http.server 8080
Backend CORS config for dev:
const allowedOrigins = ['http://localhost:8080'];
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-Methods', 'GET,POST,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
Frontend request:
fetch('http://localhost:5000/data')
.then(r => r.json())
.then(console.log)
.catch(console.error);
That setup behaves like the web. file:// doesn’t.
If you’re also tightening other browser defenses around your app, check the official docs for CORS behavior and, for broader header hardening beyond CORS, resources like CSP Guide.