CORS Security Architecture: Beyond Access-Control-Allow-Origin
You add Access-Control-Allow-Origin: * to your API, the browser error disappears, and you move on. Weeks later, a security audit flags it as a vulnerability. CORS is one of the most misunderstood mechanisms in web security — getting it right requires understanding why the browser enforces these rules and how the headers interact.
The Same-Origin Policy#
The same-origin policy (SOP) is the browser's foundational security boundary. Two URLs share an origin only if their scheme, host, and port all match.
| URL A | URL B | Same origin? |
|---|---|---|
https://app.example.com | https://app.example.com/api | Yes |
https://app.example.com | http://app.example.com | No (scheme) |
https://app.example.com | https://api.example.com | No (host) |
https://app.example.com | https://app.example.com:8443 | No (port) |
Without SOP, a malicious page could make authenticated requests to your bank's API using your cookies and read the response. SOP blocks the reading of cross-origin responses by default — the request may still be sent (this distinction matters).
What SOP Does NOT Block#
- Form submissions — A
POSTfrom a cross-origin form is allowed. This is why CSRF tokens exist. - Image/script/stylesheet loading — Cross-origin resources embedded via tags are permitted (though JavaScript cannot read their raw content).
- WebSocket connections — Not governed by SOP. Servers must validate the
Originheader themselves.
How CORS Works#
Cross-Origin Resource Sharing (CORS) is a relaxation mechanism — it lets a server explicitly declare which origins may read its responses.
Simple Requests#
A request is "simple" if it meets all of these conditions:
- Method is
GET,HEAD, orPOST. - Only safe headers are used (
Accept,Accept-Language,Content-Language,Content-Typewith valuesapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain). - No
ReadableStreambody.
For simple requests, the browser sends the request directly and checks the response headers:
Request:
GET /api/data HTTP/1.1
Origin: https://app.example.com
Response:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
If the Access-Control-Allow-Origin header matches the requesting origin (or is *), the browser allows JavaScript to read the response.
Preflight Requests#
Any request that is not simple triggers a preflight — an OPTIONS request the browser sends before the real request.
OPTIONS /api/data HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization
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
Key preflight response headers:
| Header | Purpose |
|---|---|
Access-Control-Allow-Methods | HTTP methods the server accepts from this origin |
Access-Control-Allow-Headers | Custom headers the server accepts |
Access-Control-Max-Age | How long (in seconds) the browser may cache the preflight result |
Credentials#
By default, cross-origin requests do not include cookies, HTTP auth, or client certificates. To include them:
- The client must set
credentials: 'include'(Fetch API) orwithCredentials: true(XMLHttpRequest). - The server must respond with
Access-Control-Allow-Credentials: true. - The server must not use
Access-Control-Allow-Origin: *— it must echo the specific requesting origin.
Request:
GET /api/me HTTP/1.1
Origin: https://app.example.com
Cookie: session=abc123
Response:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Exposing Response Headers#
By default the browser only exposes "CORS-safelisted" response headers to JavaScript (Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma). To expose custom headers:
Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Remaining
Common Misconfigurations#
1. Wildcard With Credentials#
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
This is rejected by the browser. When credentials are involved, the origin must be explicit. However, some servers work around this by dynamically reflecting the Origin header — which introduces the next problem.
2. Reflecting Origin Without Validation#
# DANGEROUS: reflects any origin
response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin')
response.headers['Access-Control-Allow-Credentials'] = 'true'
This effectively disables the same-origin policy. Any malicious site can make authenticated requests and read responses. Always validate against an allowlist.
3. Null Origin Trust#
Access-Control-Allow-Origin: null
The null origin appears in requests from file:// URLs, sandboxed iframes, and redirects. Trusting it gives attackers an easy vector — a sandboxed iframe on a malicious page sends Origin: null.
4. Subdomain Wildcard via Regex#
# Intended: allow *.example.com
# Actual: also allows evil-example.com
if origin.endswith('example.com'):
allow(origin)
Always match the full origin including the dot prefix: .example.com or use an exact domain list.
5. Forgetting Vary: Origin#
When the Access-Control-Allow-Origin header varies per request, the server must include Vary: Origin so CDNs and proxies do not cache a response for one origin and serve it to another.
Secure Configuration Patterns#
Allowlist Approach#
ALLOWED_ORIGINS = {
'https://app.example.com',
'https://staging.example.com',
'https://admin.example.com',
}
def cors_middleware(request, 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['Vary'] = 'Origin'
return response
Per-Route CORS#
Not every endpoint needs the same policy. Public APIs may allow *, while authenticated endpoints should be locked to specific origins.
/api/public/* --> Access-Control-Allow-Origin: *
/api/user/* --> Access-Control-Allow-Origin: https://app.example.com
/api/admin/* --> No CORS headers (same-origin only)
Preflight Caching#
Set Access-Control-Max-Age to reduce preflight overhead. A value of 86400 (24 hours) is a reasonable maximum. Note that browsers cap this value — Chrome limits it to 2 hours, Firefox to 24 hours.
Proxy Patterns#
When you cannot control the server's CORS headers, a proxy can solve the problem without weakening security.
Reverse Proxy (Same-Origin)#
Place your API behind the same origin as your frontend:
https://app.example.com/ --> Frontend (Next.js, Vite, etc.)
https://app.example.com/api/ --> Proxied to backend service
No CORS headers needed because the browser sees a same-origin request. This is the simplest and most secure approach.
BFF (Backend for Frontend)#
A dedicated backend that your frontend calls same-origin. The BFF then calls downstream APIs server-to-server where CORS does not apply.
Browser --> BFF (same origin) --> Payment API (server-to-server)
--> Inventory API (server-to-server)
API Gateway CORS#
Centralize CORS handling in your API gateway (Kong, AWS API Gateway, Envoy) rather than configuring each microservice individually. This ensures consistent policy enforcement.
# Kong CORS plugin configuration
plugins:
- name: cors
config:
origins:
- https://app.example.com
- https://staging.example.com
methods:
- GET
- POST
- PUT
- DELETE
headers:
- Content-Type
- Authorization
credentials: true
max_age: 86400
Security Checklist#
- Never use
Access-Control-Allow-Origin: *with credentials. - Validate origins against an explicit allowlist — no regex substring matches.
- Do not trust the
nullorigin. - Include
Vary: Originwhen the header changes per request. - Scope CORS per route — public endpoints get broad access, authenticated endpoints get narrow access.
- Cache preflights with
Access-Control-Max-Ageto reduce latency. - Prefer same-origin proxying over cross-origin requests when possible.
- Audit regularly — test with
curl -H "Origin: https://evil.com"and verify the response rejects unknown origins. - Complement CORS with other defenses — CSRF tokens, SameSite cookies, Content Security Policy.
CORS is not a security feature — it is a controlled relaxation of the same-origin policy. The most secure configuration is no CORS at all: serve everything from the same origin. When cross-origin access is unavoidable, treat every header as a trust boundary and configure it with the same care you apply to authentication.
This is article #289 on Codelit.io — leveling up your engineering knowledge, one deep dive at a time. Explore more at codelit.io.
Continue learning
Go deeper on security
As an Amazon Associate I earn from qualifying purchases. Codelit may receive a commission at no extra cost to you.
Real-World Cryptography
David Wong · 2021
Which primitive to pick for TLS, JWTs or end-to-end messaging, and what breaks when you pick wrong.
4.7 (100)#14 in Client-Server Networking SystemsSolving Identity Management in Modern Applications
Yvonne Wilson, Abhishek Hingnikar · 2022
Every OAuth 2 and OIDC flow drawn end to end, including which to pick and the tokens each returns.
4.0 (21)Kindle editionRelease It!
Michael T. Nygard · 2018
Post-mortems of real outages, then the stability patterns (circuit breaker, bulkhead) that stop them.
4.6 (501)#4 in Client-Server Networking SystemsA Philosophy of Software Design
John Ousterhout · 2021
Short book on module depth and complexity, with before/after code showing why an interface is too wide.