CORS and Its Misconfigurations
A Relaxation, Not a Protection
Cross-Origin Resource Sharing (CORS) is often mistaken for a security feature you "turn on". It is the opposite: the browser's same-origin policy already blocks cross-origin reads, and CORS is how your server tells the browser which other origins it may relax that rule for. Every CORS header you send widens access. Getting it wrong means any website a logged-in user visits can read that user's data from your API.
What the Same-Origin Policy Actually Blocks
An origin is the triple scheme + host + port. Compared with https://app.example.com:
| URL | Same origin? | Why |
|---|---|---|
https://app.example.com/api/me |
Yes | Path does not matter |
http://app.example.com |
No | Different scheme |
https://api.example.com |
No | Different host (same site, though) |
https://app.example.com:8443 |
No | Different port |
The policy stops script on one origin from reading responses from another. It does not stop the browser from sending requests: forms, images and fetch() calls can all reach your server from any page, which is exactly the gap CSRF exploits. CORS only controls whether the calling page's JavaScript gets to see the response.
The consequence people miss: for many cross-origin requests your server processes the request before the browser checks any CORS header. A missing Access-Control-Allow-Origin hides the response; it does not undo a state change.
Simple Requests and Preflights
The browser sorts cross-origin requests into two kinds.
A simple request is a GET, HEAD or POST that uses only CORS-safelisted headers and, for POST, a Content-Type of application/x-www-form-urlencoded, multipart/form-data or text/plain. These are requests an HTML form could already send, so the browser sends them straight away with an Origin header and afterwards decides whether to show the response to script.
Anything else (a PUT or DELETE, Content-Type: application/json, an Authorization or custom X- header) triggers a preflight: an OPTIONS request asking permission first.
OPTIONS /api/me HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, X-CSRF-Token
Access-Control-Max-Age: 600
Vary: Origin
Only if the preflight answer permits the method, the headers and the origin does the browser send the real PUT. Access-Control-Max-Age lets the browser cache that answer (browsers cap the value, so large numbers are silently reduced). Custom headers on the actual response are hidden from script unless listed in Access-Control-Expose-Headers.
Credentials Change Everything
By default a cross-origin fetch() sends no cookies. The calling page must opt in, and your server must opt in too:
// On https://app.example.com
const res = await fetch("https://api.example.com/api/me", {
credentials: "include", // send cookies for api.example.com
});
console.log(await res.json());
For the browser to expose that response, the server must reply with the exact origin in Access-Control-Allow-Origin and Access-Control-Allow-Credentials: true. The browser refuses the wildcard * together with credentials, and with credentials * in Allow-Methods or Allow-Headers is treated as a literal name rather than a wildcard.
Cookie rules apply on top. A SameSite=Lax cookie is still sent when app.example.com calls api.example.com, because they are the same site (same registrable domain). A frontend on a different registrable domain needs SameSite=None; Secure on the API cookie, and browsers that block third-party cookies may drop it anyway. Serving the API from the app's own origin through a reverse proxy avoids all of this.
A Safe Configuration in Flask
The rule: an exact allowlist, the matched origin echoed only when it is on the list, and Vary: Origin so caches keep per-origin copies apart.
from flask import Flask, jsonify, request
app = Flask(__name__)
ALLOWED_ORIGINS = {
"https://app.example.com",
"https://admin.example.com",
}
ALLOWED_METHODS = "GET, POST, PUT, DELETE"
ALLOWED_HEADERS = "Content-Type, X-CSRF-Token"
@app.after_request
def add_cors(resp):
origin = request.headers.get("Origin")
resp.vary.add("Origin") # response differs per Origin
if origin in ALLOWED_ORIGINS: # exact match, no regex, no "null"
resp.headers["Access-Control-Allow-Origin"] = origin
resp.headers["Access-Control-Allow-Credentials"] = "true"
if request.method == "OPTIONS":
resp.headers["Access-Control-Allow-Methods"] = ALLOWED_METHODS
resp.headers["Access-Control-Allow-Headers"] = ALLOWED_HEADERS
resp.headers["Access-Control-Max-Age"] = "600"
return resp
@app.route("/api/me", methods=["GET", "OPTIONS"])
def me():
if request.method == "OPTIONS":
return "", 204
return jsonify(user="ada", plan="pro")
Exercising it with Flask's test client for four Origin values prints:
https://app.example.com -> {'Vary': 'Origin', 'Access-Control-Allow-Origin': 'https://app.example.com', 'Access-Control-Allow-Credentials': 'true'}
https://app.example.com.attacker.net -> {'Vary': 'Origin'}
null -> {'Vary': 'Origin'}
None -> {'Vary': 'Origin'}
Unlisted origins get no CORS headers, so the browser withholds the response; the missing header is the denial.
Library Configurations and Their Defaults
Most apps use a library, and the defaults are where trouble starts. With flask-cors, pass the origins explicitly:
from flask_cors import CORS
CORS(app,
resources={r"/api/*": {"origins": ["https://app.example.com"]}},
supports_credentials=True,
methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Content-Type", "X-CSRF-Token"],
max_age=600)
Calling CORS(app, supports_credentials=True) with no origins allows every origin; in testing with flask-cors 6.0 it echoed an arbitrary Origin back together with Access-Control-Allow-Credentials: true, which is the most dangerous configuration there is. The same test showed no Vary: Origin when only one origin was configured, so add it yourself. Entries in origins may also be regular expressions, which brings in the matching bugs below.
In Express, the cors package reflects any request origin when passed origin: true; pass an array of exact origins, such as cors({ origin: ["https://app.example.com"], credentials: true }).
At nginx, a map gives exact matching; add_header skips a header whose value is empty, so unlisted origins get nothing:
map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
}
server {
location /api/ {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Vary Origin always;
proxy_pass http://backend;
}
}
Set CORS in one layer only. If both nginx and the app add Access-Control-Allow-Origin, the response carries two values and the browser rejects it, which usually leads someone to "fix" it with a wildcard.
The Misconfigurations That Matter
- Reflecting any origin with credentials. Code that copies
OriginintoAccess-Control-Allow-Originwithout checking it, plusAllow-Credentials: true, lets every website read authenticated responses. It usually appears because*failed with credentials and reflection "worked". - Allowing
null. Browsers sendOrigin: nullfrom sandboxed iframes,file:pages and some redirect chains, and any page can create a sandboxed iframe. Trustingnullis trusting everyone. Local development should use a realhttp://localhost:portorigin in a dev-only allowlist instead. - Loose matching.
origin.endswith("example.com")acceptsnotexample.com.origin.startswith("https://app.example.com")acceptshttps://app.example.com.other.net. An unanchored regex or an unescaped dot (app.example.commatchesappXexample.com) has the same problem. Exact set membership avoids the whole class. - Trusting every subdomain. A pattern for
*.example.commeans an XSS on a forgotten marketing subdomain, or a dangling DNS record someone takes over, becomes a way to read your API. List the origins that genuinely need access. - Allowing
http://origins for an HTTPS API. Anyone on the network path of a plain-HTTP page can inject script into it and use its trusted status. - Missing
Vary: Origin. If a CDN or shared cache stores a response with one origin'sAccess-Control-Allow-Origin, it can serve that header to other origins, breaking legitimate clients or granting access. See web cache poisoning for how cache keys go wrong. *on internal APIs. A wildcard without credentials is fine for truly public data, but an API that trusts network location (an intranet dashboard, a service onlocalhost) is readable by any website an employee visits, because their browser is inside the network.- Treating CORS as access control. CORS decides what browsers show;
curland server-side code ignore it completely. Authentication, authorization and CSRF defences are still required on every endpoint.
Detecting It in Your Own App
Checking takes seconds with curl against your own API: send an Origin you do not trust and see whether it comes back.
curl -si https://api.example.com/api/me -H "Origin: https://unrelated.invalid" | grep -i '^access-control'
No output is the correct result. To cover the common mistakes on every deploy, script the probes from your own trusted origin:
import sys
import requests
def probe_origins(trusted: str) -> dict:
'''Origins a correct allowlist must reject, derived from one trusted origin.'''
scheme, host = trusted.split("://", 1)
base = ".".join(host.split(".")[-2:]) # example.com (naive for co.uk etc.)
return {
"unrelated site": "https://unrelated.invalid",
"suffix look-alike": f"{scheme}://not{base}",
"trusted as prefix": f"{trusted}.unrelated.invalid",
"plain http": f"http://{host}",
"null origin": "null",
}
def audit(url: str, trusted: str) -> list[str]:
findings = []
r = requests.get(url, headers={"Origin": trusted}, timeout=10)
if r.headers.get("Access-Control-Allow-Origin") and "origin" not in r.headers.get("Vary", "").lower():
findings.append("ACAO set without Vary: Origin (shared caches may serve the wrong header)")
for label, origin in probe_origins(trusted).items():
r = requests.get(url, headers={"Origin": origin}, timeout=10)
acao = r.headers.get("Access-Control-Allow-Origin")
creds = r.headers.get("Access-Control-Allow-Credentials") == "true"
if acao == origin or (acao == "*" and label != "null origin"):
findings.append(f"{label}: allowed {origin!r}" + (" WITH credentials" if creds else ""))
return findings
if __name__ == "__main__":
url, trusted = sys.argv[1], sys.argv[2]
results = audit(url, trusted)
print("\n".join(results) or "no CORS issues found for the probed origins")
sys.exit(1 if results else 0)
Against a local test app that used an endswith("example.com") check and also allowed null, it printed:
ACAO set without Vary: Origin (shared caches may serve the wrong header)
suffix look-alike: allowed 'https://notexample.com' WITH credentials
plain http: allowed 'http://app.example.com' WITH credentials
null origin: allowed 'null' WITH credentials
Against the Flask configuration above it printed no CORS issues found for the probed origins and exited 0, so the script can gate a CI job. Run it against several routes (an authenticated JSON endpoint, an error page, a file download), because CORS is often configured per route. In the browser, a blocked response appears in the DevTools console as a CORS error naming the missing or mismatched header.
Choosing a Policy
| API type | Policy |
|---|---|
| Same-origin app (frontend and API behind one host) | No CORS headers at all |
| Your SPA on another origin, cookie auth | Exact allowlist + Allow-Credentials: true + Vary: Origin + CSRF defences |
Your SPA, bearer token in Authorization header |
Exact allowlist with Authorization in Allow-Headers; no credentials flag |
| Truly public, unauthenticated data | Access-Control-Allow-Origin: *, never with credentials |
CORS sits alongside the browser-side controls in security headers; when your SPA gets tokens from an identity provider, the OAuth and OIDC lesson covers keeping them out of script's reach.