You fetch('https://api.yourdomain.com/users') from the frontend and the console screams:
Access to fetch at 'https://api.yourdomain.com/users' from origin
'https://app.yourdomain.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Meanwhile Postman or curl works fine — classic CORS. CORS is enforced by the browser, not the server, but the fix is server-side: tell the browser “I allow origin X to call me.”
Mental model: before cross-origin requests, the browser sends an OPTIONS preflight. The server must reply with Access-Control-Allow-Origin and friends. Preflight fails or main response lacks headers → browser rejects.
Common causes
Ordered by hit rate, highest first.
1. Server never sends Access-Control-Allow-Origin
Most common. Fresh project has no CORS middleware. Every cross-origin request fails.
How to spot it: Network → Response Headers — no Access-Control-Allow-Origin.
2. OPTIONS preflight isn’t handled
POST/PUT/DELETE or custom-header requests trigger preflight. If the server returns 404/405 on OPTIONS, the main request never fires.
How to spot it: Network shows an OPTIONS request with non-2xx status.
3. Origin string mismatch (scheme / port / trailing slash)
✅ Access-Control-Allow-Origin: https://app.example.com
❌ Access-Control-Allow-Origin: app.example.com # missing scheme
❌ Access-Control-Allow-Origin: https://app.example.com/ # extra /
❌ Access-Control-Allow-Origin: http://app.example.com # http vs https
Exact match — one character off and it breaks.
How to spot it: Compare response Access-Control-Allow-Origin to request Origin header.
4. credentials: include with wildcard *
Browser rule: credentialed requests require a specific origin (not *), plus Access-Control-Allow-Credentials: true.
How to spot it: Error contains “The value of the ‘Access-Control-Allow-Origin’ header in the response must not be the wildcard ’*‘“
5. Missing Allow-Headers / Allow-Methods
Frontend sends Content-Type: application/json or custom X-API-Key — both trigger preflight. If server doesn’t list them in Access-Control-Allow-Headers, preflight fails.
How to spot it: Error contains “Request header field X-API-Key is not allowed.”
6. CDN / proxy strips CORS headers
Cloudflare / nginx / API Gateway can strip or overwrite response headers. Server sends correct headers, browser sees none.
How to spot it: curl origin directly (bypass CDN) — compare against what the browser sees.
Shortest path to fix
Step 1: Add CORS middleware server-side
// Express
import cors from 'cors';
app.use(cors({
origin: [
'https://app.yourdomain.com',
'http://localhost:3000', // dev
],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
}));
# FastAPI
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.yourdomain.com", "http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
// Hono
import { cors } from 'hono/cors';
app.use('/*', cors({
origin: ['https://app.yourdomain.com', 'http://localhost:3000'],
credentials: true,
}));
Step 2: Handle OPTIONS preflight
Most CORS middleware handles OPTIONS automatically. If you roll your own:
app.options('/api/*', (req, res) => {
res.set({
'Access-Control-Allow-Origin': req.headers.origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400', // cache 24h
});
res.sendStatus(204);
});
Step 3: Verify with curl
# Simulate preflight
curl -i -X OPTIONS https://api.yourdomain.com/users \
-H "Origin: https://app.yourdomain.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type"
# Should see:
# Access-Control-Allow-Origin: https://app.yourdomain.com
# Access-Control-Allow-Methods: ...
# Access-Control-Allow-Headers: Content-Type
Step 4: Handle credentials properly
If the frontend uses fetch(url, { credentials: 'include' }):
// Cannot use origin: '*' — must enumerate
app.use(cors({
origin: (origin, cb) => {
const allowed = ['https://app.yourdomain.com', 'http://localhost:3000'];
cb(null, allowed.includes(origin));
},
credentials: true,
}));
Step 5: CDN / proxy investigation
# Direct to origin (bypass CDN)
curl -i https://origin-server-ip/api/users \
-H "Host: api.yourdomain.com" \
-H "Origin: https://app.yourdomain.com"
# Through CDN
curl -i https://api.yourdomain.com/users \
-H "Origin: https://app.yourdomain.com"
If headers are present direct and missing through CDN, the CDN stripped them. Cloudflare: check Transform Rules / Modify Response Header. nginx: confirm proxy_pass_header includes the CORS headers.
Step 6: Dev-only browser bypass
For local debugging only:
# Chrome with CORS disabled
open -na "Google Chrome" --args \
--user-data-dir="/tmp/chrome_dev" \
--disable-web-security
Dev only — production must have correct CORS.
Prevention
- Include the prod origin in CORS during local dev so you don’t discover the gap on deploy
- Use one shared CORS helper — don’t write per-route configs
- Env-driven config:
CORS_ORIGINS=https://app.example.com,http://localhost:3000then split - Cache preflight (
Access-Control-Max-Age: 86400) to reduce repeat OPTIONS - Never use
origin: '*'withcredentials: truein production — browser rejects and it’s a security risk - After API Gateway / CDN config changes, run a curl OPTIONS smoke test
- Monitor “CORS preflight 4xx” — catch misconfigurations early
Related
Tags: #Backend #Debug #Troubleshooting #CORS