CORS Error Calling Your Own API

Browser blocks request with CORS error — server config or wrong origin.

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:3000 then split
  • Cache preflight (Access-Control-Max-Age: 86400) to reduce repeat OPTIONS
  • Never use origin: '*' with credentials: true in 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

Tags: #Backend #Debug #Troubleshooting #CORS