CORS
Every frontend dev has fought it, few understand it
In a nutshell
When your frontend at app.example.com tries to call your API at api.example.com, the browser blocks it by default -- that's the Same-Origin Policy. CORS is the mechanism your API uses to explicitly say "yes, I allow requests from this origin." Without it, your API works fine in Postman but breaks in the browser.
The situation
You deploy your React app on app.example.com and your API on api.example.com. The first API call from the browser fails with: "Access to fetch at 'https://api.example.com' from origin 'https://app.example.com' has been blocked by CORS policy." Someone Googles it and adds Access-Control-Allow-Origin: * to the server. The error disappears. The security hole opens.
What CORS actually is
CORS (Cross-Origin Resource Sharing) is a browser-enforced mechanism. It exists because browsers follow the Same-Origin Policy: JavaScript on app.example.com cannot make requests to api.example.com unless the API explicitly allows it.
Key point: CORS does not protect your API. It protects users' browsers from malicious sites making requests to your API using the user's cookies. curl, Postman, and server-to-server calls are completely unaffected by CORS — they don't have a browser enforcing it.
The mental model
CORS is the API saying "I consent to being called from this origin." Without that consent, the browser refuses to show the response to JavaScript. The request might still reach your server — the browser just won't let the calling code see the response.
The preflight flow
When a browser makes a "non-simple" cross-origin request (anything with custom headers, JSON content type, or methods other than GET/POST/HEAD), it sends a preflight request first. Here's the flow at a glance:
Step 1: Browser sends OPTIONS preflight
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, AuthorizationThe browser is asking: "Will you accept a POST request from app.example.com with these headers?"
Step 2: Server responds with CORS headers
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Access-Control-Allow-Credentials: trueThe server says: "Yes, I accept requests from that origin, with those methods and headers. Cache this response for 24 hours so you don't have to ask again."
Step 3: Browser sends the actual request
POST /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
{
"name": "Alice Johnson",
"email": "alice@example.com"
}Step 4: Server responds with CORS headers on the actual response
HTTP/1.1 201 Created
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Content-Type: application/json
{
"id": "usr_8a3f",
"name": "Alice Johnson",
"email": "alice@example.com"
}Testing with curl
You can simulate the preflight and actual request yourself:
# Simulate the preflight
curl -X OPTIONS https://api.example.com/api/users \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization" \
-v 2>&1 | grep -i "access-control"< Access-Control-Allow-Origin: https://app.example.com
< Access-Control-Allow-Methods: GET, POST, PUT, DELETE
< Access-Control-Allow-Headers: Content-Type, Authorization
< Access-Control-Allow-Credentials: true
< Access-Control-Max-Age: 86400# Simulate the actual request
curl -X POST https://api.example.com/api/users \
-H "Origin: https://app.example.com" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \
-d '{"name": "Alice", "email": "alice@example.com"}'The CORS headers explained
| Header | Purpose | Example |
|---|---|---|
Access-Control-Allow-Origin | Which origins can access the API | https://app.example.com |
Access-Control-Allow-Methods | Which HTTP methods are allowed | GET, POST, PUT, DELETE |
Access-Control-Allow-Headers | Which request headers are allowed | Content-Type, Authorization |
Access-Control-Allow-Credentials | Whether cookies/auth headers are sent | true |
Access-Control-Max-Age | How long to cache the preflight (seconds) | 86400 |
Access-Control-Expose-Headers | Which response headers JS can read | X-Request-Id, X-RateLimit-Remaining |
The danger of Access-Control-Allow-Origin: *
The wildcard allows any website to make requests to your API. If a malicious site includes JavaScript that calls your API, the browser will allow it.
# Overly permissive — any site can call your API
Access-Control-Allow-Origin: *This is fine for truly public APIs with no authentication (weather data, public datasets). It's dangerous for anything that uses cookies or session tokens, because * cannot be combined with Access-Control-Allow-Credentials: true — but developers work around this by dynamically reflecting the Origin header, which is even worse.
The reflection anti-pattern
# NEVER DO THIS — reflects any origin, including attacker-controlled ones
@app.after_request
def add_cors(response):
origin = request.headers.get("Origin")
response.headers["Access-Control-Allow-Origin"] = origin # reflects anything
response.headers["Access-Control-Allow-Credentials"] = "true"
return responseThis is equivalent to * but bypasses the browser's safety check that prevents * + credentials. An attacker's site at evil.com sends a request with the user's cookies, and your server happily responds with Access-Control-Allow-Origin: https://evil.com.
Origin reflection = no CORS at all
Reflecting the request's Origin header back as the allowed origin defeats the entire purpose of CORS. An attacker hosts a page that makes requests to your API, and the user's browser will happily send cookies and show the response. Always validate the Origin against an explicit allowlist.
Properly configured CORS
ALLOWED_ORIGINS = {
"https://app.example.com",
"https://staging.example.com",
"http://localhost:3000", # development only
}
@app.after_request
def add_cors(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"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE"
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
response.headers["Access-Control-Max-Age"] = "86400"
return responseCompare the responses:
# Request from allowed origin
Origin: https://app.example.com
# Response:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true# Request from unknown origin
Origin: https://evil.com
# Response:
# (no CORS headers — browser blocks the response)Avoid localhost in production
Keep http://localhost:3000 in your allowlist only in development. Use environment variables to control which origins are allowed per environment. Production should list only production domains.
Simple vs preflighted requests
Not every cross-origin request triggers a preflight. Simple requests skip the OPTIONS step:
| Simple (no preflight) | Non-simple (preflight required) |
|---|---|
| Methods: GET, HEAD, POST | Methods: PUT, DELETE, PATCH |
| Headers: Accept, Content-Language, Content-Type | Custom headers: Authorization, X-Request-Id |
| Content-Type: text/plain, multipart/form-data, application/x-www-form-urlencoded | Content-Type: application/json |
Since most APIs use Authorization headers and application/json, almost every request triggers a preflight. The Access-Control-Max-Age header reduces the overhead by caching the preflight result.
Checklist: CORS configuration
- Am I using an explicit origin allowlist, not
*or origin reflection? - Is
Access-Control-Allow-Credentialsonly set when I need cookies/auth headers? - Is
Access-Control-Max-Ageset to avoid repeated preflights? - Are
localhostorigins excluded in production? - Am I only exposing the response headers that clients actually need?
Next up: secrets management — because API keys in URLs end up in logs, always.