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 presentResponse to preflight request doesn't pass access control checkThe 'Access-Control-Allow-Origin' header contains multiple valuesCredentials 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 Headerssection - If
Access-Control-Allow-Originis 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
OPTIONSrequest 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://localhostbut request came fromhttp://localhost:3000— wrong (port must match) - Server allows
https://myapp.combut frontend useshttps://www.myapp.com— wrong (www is a different host) - Server allows
http://localhost:3000but the browser tab loadedhttp://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:
- Explicit list of allowed origins (never
*for production APIs with auth) credentials: trueif the app uses cookies or Authorization headers- All custom headers the frontend sends listed in
allowedHeaders - Preflight (OPTIONS) handled automatically by middleware, not custom code
- 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.