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.
Try it on Codelit
Chaos Mode
Simulate node failures and watch cascading impact across your architecture
Related articles
Build this architecture
Generate an interactive CORS Security Architecture in seconds.
Try it in Codelit →
Comments