Web Dev

Fix: CORS error blocking API calls from localhost

Access-Control-Allow-Origin errors kill frontend/backend development on localhost. Here's what CORS actually blocks and how to fix it properly.

The browser console shows:

Access to fetch at 'http://localhost:8000/api/users' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.

The API call never leaves the browser. Postman works fine, the backend logs show no request, and only the browser sees the failure. Here’s what’s happening and how to fix it correctly.

What CORS actually is

CORS (Cross-Origin Resource Sharing) is a browser-enforced rule. When JavaScript on http://localhost:3000 tries to call an API at http://localhost:8000, the browser considers them different origins (different port = different origin) and asks the server “do you allow requests from localhost:3000?” via HTTP headers.

If the server doesn’t say yes, the browser blocks the response before your JavaScript sees it.

Two important consequences:

  • The request usually does reach the server. The browser blocks the response, not the request. Log inspection on the server can be misleading.
  • CORS is a browser policy. curl, Postman, backend-to-backend calls, and mobile apps ignore it entirely. If the API works in Postman but not the browser, CORS is the cause.

Diagnose — is this actually CORS?

Read the console error carefully. Real CORS errors mention one of:

  • No 'Access-Control-Allow-Origin' header is present
  • Response to preflight request doesn't pass access control check
  • The 'Access-Control-Allow-Origin' header contains multiple values
  • Credentials mode is 'include' but the ... header is '*'

If the error is different (network failure, 500, DNS not found), it isn’t CORS — check the actual response in the Network tab.

Check the actual server response headers in DevTools:

  • Network tab → click the failing request → Headers tab
  • Look at Response Headers section
  • If Access-Control-Allow-Origin is missing, the server isn’t sending it

Cause 1 — Backend has no CORS middleware

Most common. The backend was built for API-only use (mobile app, server-to-server) and never configured to accept browser calls.

Fix — add CORS middleware. Examples for common stacks:

Express (Node.js):

const cors = require('cors');
app.use(cors({
  origin: ['http://localhost:3000', 'https://myapp.com'],
  credentials: true,
}));

FastAPI (Python):

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000", "https://myapp.com"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

Flask (Python):

from flask_cors import CORS
CORS(app, origins=["http://localhost:3000"], supports_credentials=True)

Django:

# settings.py
INSTALLED_APPS = [..., 'corsheaders']
MIDDLEWARE = ['corsheaders.middleware.CorsMiddleware', ...]  # near top
CORS_ALLOWED_ORIGINS = ["http://localhost:3000"]
CORS_ALLOW_CREDENTIALS = True

Go (Gin):

import "github.com/gin-contrib/cors"

r.Use(cors.New(cors.Config{
    AllowOrigins:     []string{"http://localhost:3000"},
    AllowCredentials: true,
}))

Restart the server. The error should clear.

Cause 2 — Preflight (OPTIONS) request failing

For requests with custom headers, non-GET methods, or credentials, browsers first send an OPTIONS preflight to ask permission. If the server responds 404 or 405 to OPTIONS, the actual request never fires.

Diagnose — Network tab shows two entries:

  • An OPTIONS request that failed
  • The intended request that never ran

Fix — the CORS middleware examples above handle OPTIONS automatically. If you wrote CORS handling manually, ensure it also responds to OPTIONS:

app.options('/*splat', cors());  // Express 5+ (v4 syntax was '*', now throws with path-to-regexp v8)

Cause 3 — Wildcard origin with credentials

Cannot combine Access-Control-Allow-Origin: * with credentials: 'include'. The browser rejects this specifically:

The value of the 'Access-Control-Allow-Origin' header in the response must not be
the wildcard '*' when the request's credentials mode is 'include'.

Fix — list explicit origins:

app.use(cors({
  origin: ['http://localhost:3000'],  // explicit, not '*'
  credentials: true,
}));

If you don’t need credentials (cookies, auth headers), drop credentials: true and * becomes valid again.

Cause 4 — Wrong origin in the allowlist

Origins are exact-match: protocol + host + port. Common mistakes:

  • Server allows http://localhost but request came from http://localhost:3000 — wrong (port must match)
  • Server allows https://myapp.com but frontend uses https://www.myapp.com — wrong (www is a different host)
  • Server allows http://localhost:3000 but the browser tab loaded http://127.0.0.1:3000 — wrong (localhost ≠ 127.0.0.1 to the browser)

Fix: either add every variant to the allowlist, or use a function to allow patterns:

app.use(cors({
  origin: (origin, cb) => {
    if (!origin) return cb(null, true);
    if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
      return cb(null, true);
    }
    if (origin === 'https://myapp.com') return cb(null, true);
    cb(new Error('Not allowed'));
  },
  credentials: true,
}));

Cause 5 — Custom header sent, not allowed

Sending Authorization, X-API-Key, or a custom header triggers preflight. If the server doesn’t list that header in Access-Control-Allow-Headers, browser blocks.

Fix:

app.use(cors({
  origin: ['http://localhost:3000'],
  credentials: true,
  allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
}));

Or '*' if you don’t care (only OK when credentials: false).

Cause 6 — CDN or proxy strips CORS headers

Backend sends correct headers, but Cloudflare / API Gateway / nginx in front removes them. Response body reaches browser without the CORS header, browser blocks.

Diagnose: curl the endpoint directly (bypassing browser):

curl -i -H "Origin: http://localhost:3000" http://localhost:8000/api/users

If curl shows Access-Control-Allow-Origin in the response, the browser should too — the CDN or proxy is stripping it. If curl doesn’t show it either, the backend isn’t sending it.

Fix depends on the layer:

  • nginx: add_header 'Access-Control-Allow-Origin' 'http://localhost:3000' always;
  • Cloudflare: Rules → Transform Rules → HTTP Response Header Modification
  • AWS API Gateway: enable CORS in the console for each resource + method

Cause 7 — Third-party API not under your control

Trying to fetch data from a public API (api.somesite.com) that doesn’t send CORS headers for your origin. You can’t change their server.

Fix — proxy through your own backend:

Add a route to your backend that forwards the request:

// Express example
app.get('/api/proxy/somesite', async (req, res) => {
  const r = await fetch('https://api.somesite.com/data');
  const data = await r.json();
  res.json(data);
});

Browser now calls your backend (same origin), backend calls the third party (server-to-server, no CORS). Standard pattern for scraping and public data ingestion.

Dev-only quick fixes (do not ship these)

For rapid iteration on localhost, when the fix is one you’ll do later:

Vite dev proxy — routes /api/* calls to your backend, avoiding CORS entirely in development:

// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8000',
        changeOrigin: true,
      },
    },
  },
};

Next.js dev proxy:

// next.config.js
module.exports = {
  async rewrites() {
    return [
      { source: '/api/:path*', destination: 'http://localhost:8000/api/:path*' },
    ];
  },
};

Chrome with CORS disabled (LAST resort, insecure, isolated profile only):

google-chrome --disable-web-security --user-data-dir=/tmp/chrome-dev

Never browse the real web with that flag on — it removes a fundamental browser protection.

The universal CORS debug flow

Every CORS error, in order:

# 1. Read the exact error phrase
# 2. Confirm it's really CORS with a direct curl
curl -i -H "Origin: http://localhost:3000" \
     -H "Access-Control-Request-Method: POST" \
     -H "Access-Control-Request-Headers: Content-Type" \
     -X OPTIONS <url>   # real browser preflights always include the last two headers

# 3. Check whether Access-Control-Allow-Origin appears in the response
# 4. If missing at server: add CORS middleware
# 5. If present at server but stripped: check nginx/CDN/proxy
# 6. If credentials mode: ensure explicit origin, not wildcard
# 7. If custom header: add to allowedHeaders

Step 4 fixes ~80% of localhost CORS errors.

Prevention — CORS setup for every new backend

Before the first frontend integration, configure CORS with:

  1. Explicit list of allowed origins (never * for production APIs with auth)
  2. credentials: true if the app uses cookies or Authorization headers
  3. All custom headers the frontend sends listed in allowedHeaders
  4. Preflight (OPTIONS) handled automatically by middleware, not custom code
  5. Env-var-driven allowlist so localhost, staging, and prod can differ:
const allowedOrigins = process.env.CORS_ORIGINS.split(',');
app.use(cors({ origin: allowedOrigins, credentials: true }));

Bottom line

CORS errors are a browser telling you the server didn’t grant permission for cross-origin access. The server-side fix is one middleware line in almost every language. Wildcard origin plus credentials is the one impossible combination — everything else is a config issue with a clear cause. Set up CORS thoughtfully on the backend from day one and it becomes invisible.