CORS (Cross-Origin Resource Sharing) is one of the most misunderstood concepts in web development. The frontend developer says “there’s a CORS error”, the backend developer starts adding random headers, the problem sometimes goes away and sometimes does not. The only way to break that pattern is to understand how CORS actually works.
I have been doing full-stack development for more than 15 years and debugged dozens of CORS issues. This post is about the mental model and the common pitfalls.
What CORS is for
The browser’s Same-Origin Policy (SOP) is the security foundation of the web. One origin (protocol + domain + port) cannot read another origin’s resources. Without that rule:
- Visiting example.com would let malicious example.com code fire requests at bank.com using your cookies.
- Your password could be stolen, your account data scraped.
SOP blocks that. But there are legitimate cross-origin needs too: your-frontend.com calling your-api.com.
CORS is the mechanism that enables those legitimate scenarios. The browser and server negotiate: “do you accept requests from this origin?”
Simple request vs preflight
CORS splits into two categories.
Simple requests: if these conditions hold, the browser sends the request directly (no preflight):
Method is GET, HEAD, or POST. Content-Type is application/x-www-form-urlencoded, multipart/form-data, or text/plain. Only standard headers (Accept, Content-Type, etc.).
Preflight requests: anything outside that. The browser first sends an OPTIONS request, and only if the server says “yes, I accept this” does the real request go through.
Modern APIs usually use JSON body plus PUT/DELETE, so preflight is the default case.
How preflight works
The browser is going to POST to the API with Content-Type: application/json. Not a simple request, preflight required.
Step 1: browser sends OPTIONS:
OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, AuthorizationStep 2: server replies:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, GET, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400Step 3: browser sends the actual POST:
POST /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Content-Type: application/json
Authorization: Bearer token...
{...}Do not forget preflight: every complex request runs this cycle. Extra latency, but only once. With Max-Age the browser caches the response (default 5 minutes, browser-specific).
Common CORS errors
Error 1: “No ‘Access-Control-Allow-Origin’ header is present”
The most common one. The backend is either not sending a CORS header at all, or is not sending the origin the browser expects.
Fix:
Access-Control-Allow-Origin: https://app.example.comOr wildcard (careful, see below):
Access-Control-Allow-Origin: *Error 2: wildcard with credentials
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: trueThe browser rejects this. You cannot combine a wildcard with credentials (cookie, Authorization header).
Fix: use a specific origin.
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: trueIf you support multiple origins, echo back the Origin from the request dynamically (with validation).
Error 3: OPTIONS request getting caught by auth
Authentication middleware blocks the OPTIONS request too. The preflight check never happens, it just returns 401.
Fix: exempt OPTIONS requests from the authentication middleware.
# Example (Python/Flask-style)
@app.before_request
def authenticate():
if request.method == 'OPTIONS':
return # Skip auth for preflight
# ... normal auth ...Error 4: not allowing a header
Frontend is sending a custom header (for example X-Requested-With). The backend does not list that header in Access-Control-Allow-Headers.
Fix:
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-WithList every custom header explicitly.
Error 5: not exposing response headers
By default the browser only exposes basic headers to JavaScript. For a custom header (for example X-Total-Count):
Access-Control-Expose-Headers: X-Total-Count, X-Pagination-InfoWithout this, the frontend cannot read your custom headers.
Credentials mode
In the Fetch API, credentials: 'include' sends cookies and auth headers.
Frontend:
fetch('https://api.example.com/users', {
credentials: 'include',
headers: {'Content-Type': 'application/json'}
})Backend:
Access-Control-Allow-Origin: https://app.example.com // NOT *
Access-Control-Allow-Credentials: trueNo wildcard in credentials mode. A specific origin is required.
Wildcard vs specific origin
Wildcard (*):
Simple: any origin is allowed. Credentials are not allowed. Fine for public APIs (a content API with no auth, say).
Specific origin (domain):
Only the listed domain is allowed. Credentials are allowed. Secure, recommended for authenticated APIs.
If you support multiple origins, set up a whitelist:
allowed_origins = ['https://app.example.com', 'https://staging.example.com']
@app.after_request
def add_cors_headers(response):
origin = request.headers.get('Origin')
if origin in allowed_origins:
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
return responseSubdomain CORS
app.example.com calling api.example.com is still cross-origin. Even with the same subdomain root, the browser treats it as cross.
Same fix: a specific origin whitelist.
On the cookie side, if you write the cookie to .example.com (leading dot) subdomains can access it. Write it to example.com and only that exact origin can.
CORS vs CSRF
These get confused all the time. The difference:
CORS: the browser deciding whether to let JavaScript read a cross-origin response. It controls what response the server surfaces to JavaScript.
CSRF: a pattern to prevent cross-site requests being sent without the user knowing. Mitigated with a CSRF token.
CORS is access control, not security. CORS does block a CSRF attack, but it is not enough on its own (legacy browsers, specific scenarios). You still need a CSRF token.
CORS in development
In local development the API runs on a different port:
Frontend: localhost:3000. Backend: localhost:8080.
Different origins. CORS error.
Fix options:
1. Development CORS on the backend:
if environment == 'development':
allowed_origins.append('http://localhost:3000')2. Proxy: a Webpack dev server or Vite proxy makes the frontend act like same-origin.
// vite.config.js
export default {
server: {
proxy: {
'/api': 'http://localhost:8080'
}
}
}Frontend hits /api/users, Vite proxies to localhost:8080/api/users. It looks like a same-origin call.
No proxy in production, so configure CORS properly there.
Debugging CORS
You can see preflight in the Network tab.
- Is there an OPTIONS request? If not, the browser is not sending a preflight.
- What is the OPTIONS response code? Should be 200 or 204. 401 or 403 means auth issue.
- What is the Access-Control-Allow-Origin header? Does it match the request Origin?
- Does Access-Control-Allow-Methods list the method?
- Does Access-Control-Allow-Headers list the custom headers?
Walking through those five questions solves 95% of CORS issues.
Takeaway
CORS is a browser security mechanism, not a server restriction. If the backend does not send the right headers, the browser blocks the request.
Key concepts: simple vs preflight, the credentials restriction, specific origin vs wildcard. Once those click, debugging takes 5 minutes.
In production configure CORS carefully: specific origins, minimal allowed methods and headers, credentials only when required. Balance security and UX.