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.com
  • http://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:3000
  • http://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.