CORS as a Security Boundary
This is the chapter where we talk about what CORS actually protects, what it doesn't protect, and why a disturbing number of production applications have CORS configurations that might as well not exist. If the previous chapters were about making CORS work, this one is about making sure your working CORS configuration isn't a security hole.
What CORS Protects Against
Let's start with the fundamental model. CORS protects against one specific threat:
A malicious website reading data from a legitimate service using the user's ambient credentials (cookies, HTTP auth, client certificates).
Here's the attack scenario without CORS:
- You're logged into
https://mybank.com. You have a session cookie. - You visit
https://evil.example.com. - Evil's JavaScript makes a
fetch()tohttps://mybank.com/api/accounts. - The browser attaches your
mybank.comsession cookie automatically. - The bank's API returns your account data.
- Evil's JavaScript reads the response and sends your data to the attacker.
The Same-Origin Policy (and CORS as its controlled relaxation) prevents step 6.
The browser will not let evil.example.com's JavaScript read the response from
mybank.com unless mybank.com explicitly allows it via CORS headers.
Note something important: the request in step 4 still happens. The bank's server still receives it, processes it, and sends a response. CORS doesn't prevent the request. It prevents the reading of the response. This distinction matters enormously for security.
CORS Is Not a Server-Side Security Mechanism
I need to say this clearly because it's the single most dangerous misconception about CORS:
CORS is enforced by the browser. Only by the browser. Exclusively by the browser.
Your server sends CORS headers. It's the browser that reads them and decides whether to let JavaScript access the response. If something other than a browser makes the request, CORS headers are just... headers. They have no enforcement power.
# This ignores CORS entirely because curl is not a browser
curl -H "Cookie: session=stolen_value" https://mybank.com/api/accounts
# The response comes back. Full data. No CORS check.
# This ignores CORS entirely because requests is not a browser
import requests
resp = requests.get('https://mybank.com/api/accounts',
cookies={'session': 'stolen_value'})
print(resp.json()) # Full data. No CORS check.
This means:
- CORS does not protect your API from server-to-server attacks
- CORS does not protect your API from command-line tools
- CORS does not protect your API from mobile apps (they don't have CORS)
- CORS does not protect your API from browser extensions with elevated permissions
- CORS does not replace authentication
- CORS does not replace authorization
- CORS does not replace rate limiting
- CORS does not replace input validation
If you're relying on your CORS configuration as a security boundary to prevent unauthorized access to your API, you have a serious problem. CORS only protects against cross-origin reads from browsers. That's a specific and important threat vector, but it's not the only one.
The "CORS Is Not a Firewall" Principle
I see this pattern constantly:
"We don't need API authentication because we've restricted CORS to only allow requests from our frontend domain."
No. No no no no no.
Your CORS configuration says: "Browsers should only allow JavaScript from
https://myapp.com to read responses from this API." It does not say: "Only
https://myapp.com can access this API."
Anyone with curl, Postman, a Python script, or the ability to write a server-side proxy can access your API directly. Your CORS configuration is a polite request to browsers, not a firewall rule.
Here's a useful mental model: imagine your CORS configuration printed on a sign outside your building that says "Only employees may enter." That sign is respected by well-behaved visitors (browsers). But it doesn't lock the door. Anyone who wants to ignore the sign can walk right in. You still need a lock (authentication) and someone checking IDs (authorization).
CORS Does NOT Replace Authentication or Authorization
This bears repeating in its own section. Your API needs proper auth regardless of your CORS configuration:
# Without authentication, your CORS config is just cosmetic:
#
# Attacker's server → your API: no CORS, full access
# Attacker's browser → your API: CORS blocks the response
# Attacker's server → your API → attacker's browser: CORS bypassed
#
# The attacker just adds one extra hop. That's it.
The only scenario where CORS provides meaningful protection is when the user's own credentials are the thing being exploited. If the attacker can make the request themselves (without needing the victim's browser to attach cookies), CORS is irrelevant.
Reflected Origin Attacks
Here's where things get genuinely scary. A common CORS implementation pattern is to
echo back whatever Origin header the request sends:
# DANGEROUS: DO NOT DO THIS
origin = request.headers.get('Origin')
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
"But wait," you might say, "this is functionally equivalent to
Access-Control-Allow-Origin: * with credentials, which isn't allowed."
It's actually worse. Access-Control-Allow-Origin: * with
Access-Control-Allow-Credentials: true is rejected by browsers — the spec
explicitly forbids it. But echoing back the specific origin passes the browser's
CORS check. The browser sees a matching origin and credentials: true and says
"looks good to me!"
This means:
- User is logged into
https://myapp.com - User visits
https://evil.example.com - Evil sends a request to
https://api.myapp.com/user/data - The request includes
Origin: https://evil.example.com - Your server echoes back
Access-Control-Allow-Origin: https://evil.example.comandAccess-Control-Allow-Credentials: true - The browser says "the server explicitly allowed this origin with credentials, so I'll attach the cookies and let JavaScript read the response"
- Evil reads the user's data
Congratulations, you've built an API that explicitly tells browsers "yes, anyone who asks can read my users' data using their cookies." You've essentially opted out of the Same-Origin Policy entirely.
The fix: Validate the origin against an allowlist:
ALLOWED_ORIGINS = {
'https://myapp.com',
'https://admin.myapp.com',
}
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'
# If origin is not in the list, don't set CORS headers at all.
# The browser will block the cross-origin read, which is what we want.
Some developers try to be clever with regex:
# ALSO DANGEROUS: regex can be tricked
import re
if re.match(r'https://.*\.example\.com', origin):
# This matches https://evil.example.com — maybe fine
# But also https://evil-example.com if you forget to escape the dot
# And also https://foo.example.com.evil.com depending on your regex
Use an exact match against a set of known origins. If you need subdomain matching, parse the origin properly and validate the domain with proper suffix matching, not regex.
Origin Header Spoofing
"But can't an attacker just set the Origin header to whatever they want?"
In a browser: no. The Origin header is set by the browser itself and cannot be
modified by JavaScript. It's a "forbidden" request header. You can't do this:
// This does NOT work. The browser will ignore your Origin header.
fetch('https://api.example.com/data', {
headers: {
'Origin': 'https://trusted-site.com' // Browser says: nice try
}
});
The browser always sends the real origin. This is what makes CORS meaningful.
Outside a browser: yes, trivially. curl, Postman, Python, anything that isn't a
browser can set Origin to whatever it wants:
# This works fine. curl doesn't care about your feelings.
curl -H "Origin: https://trusted-site.com" https://api.example.com/data
But this doesn't break the CORS security model, because the threat model is specifically about protecting browser users. If the attacker is already making requests from their own machine (not a browser), they don't need to spoof the origin — they can just make the request directly. The origin only matters when the browser is the one making the request on behalf of JavaScript from an untrusted page.
Private Network Access
There's a relatively new security boundary being added to browsers that extends the CORS model: the Private Network Access specification (formerly known as CORS-RFC1918).
The problem it solves: a public website's JavaScript can currently make requests to your local network resources:
// From https://evil.example.com, this currently works in many browsers:
fetch('http://192.168.1.1/admin/api/config')
fetch('http://localhost:8080/debug/pprof')
fetch('http://printer.local/status')
If those internal services don't have authentication (and many don't — they're "internal," after all), a public website can interact with them using the user's network access as an ambient credential.
Private Network Access adds a new preflight check: before a public website can make a request to a private IP address (RFC 1918 ranges, localhost, link-local), the browser sends a preflight with:
Access-Control-Request-Private-Network: true
The server must respond with:
Access-Control-Allow-Private-Network: true
If it doesn't, the browser blocks the request. This is being rolled out gradually in Chrome and other Chromium-based browsers. It's not fully deployed everywhere yet, but it's coming, and if you maintain internal tools or IoT device interfaces, you should be aware of it.
# Test if your local service handles private network access preflights:
curl -v -X OPTIONS \
-H "Origin: https://evil.example.com" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Private-Network: true" \
http://localhost:8080/api/data
CORS and CSRF: Related but Different
CORS and CSRF (Cross-Site Request Forgery) are often confused because they both involve cross-origin requests. But they protect against different aspects of the same attack.
CSRF is about preventing a malicious site from causing side effects on another site using the user's credentials. The classic example: a hidden form that submits a POST to your bank's transfer endpoint.
CORS is about preventing a malicious site from reading data from another site using the user's credentials.
| CSRF | CORS | |
|---|---|---|
| Attack | Make the server do something | Read what the server returns |
| Concern | State-changing requests (POST, PUT, DELETE) | Response data |
| Defense | CSRF tokens, SameSite cookies | CORS headers |
| Key insight | The request itself is the attack | The response is the target |
Here's the critical nuance: CORS does not prevent CSRF. Remember, CORS doesn't stop the request from being sent — it stops the response from being read. A CSRF attack that submits a form to transfer money doesn't need to read the response. It just needs the request to reach the server with the user's cookies attached.
<!-- This CSRF attack works regardless of CORS configuration -->
<form action="https://bank.com/api/transfer" method="POST">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="10000" />
<input type="submit" />
</form>
<script>document.forms[0].submit();</script>
The browser will submit this form, attach the user's bank.com cookies, and the
server will process the transfer. CORS doesn't apply to form submissions (they're
not JavaScript-initiated cross-origin reads). You need CSRF protection
(tokens, SameSite cookies) to prevent this.
However, CORS can provide partial CSRF protection for API endpoints that:
- Require a
Content-Type: application/jsonheader (which triggers a preflight) - Don't respond to the preflight with permissive CORS headers
If the preflight fails, the browser won't send the actual request. But relying on this is fragile — it depends on the request being "non-simple" enough to trigger a preflight. Use proper CSRF protection. Don't rely on CORS for it.
Security Checklist for CORS Configuration
Use this checklist when reviewing your CORS configuration:
Origin validation
-
Do NOT blindly echo the
Originheader back asAccess-Control-Allow-Origin -
Validate
Originagainst an explicit allowlist of known origins - Use exact string matching, not regex (or use very carefully anchored regex)
-
Don't include
nullas an allowed origin (sandboxed iframes and redirects sendOrigin: null, and allowing it defeats the purpose) -
Don't include
localhostor development origins in production configs
Credentials
-
Only set
Access-Control-Allow-Credentials: trueif the endpoint actually needs cookies/auth from cross-origin requests -
If you set
credentials: true, you MUST NOT use wildcard*for origin, headers, or methods - Verify that the combination of credentials + allowed origin doesn't expose user data to unauthorized origins
Headers and methods
- Only allow the HTTP methods your API actually uses
- Only allow the request headers your API actually needs
- Only expose the response headers your frontend actually reads
-
Don't use wildcard
*for methods and headers if credentials are involved
Caching and Vary
-
Include
Vary: Originon every response that dynamically setsAccess-Control-Allow-Origin -
Set a reasonable
Access-Control-Max-Age(don't cache forever, but don't force a preflight on every request either) -
Verify that CDN/proxy caching respects
Vary: Origin
Error responses
- CORS headers are present on 4xx and 5xx responses, not just 2xx
- CORS headers are present on redirect (3xx) responses if applicable
Architecture
- Only one layer of your stack adds CORS headers (not both proxy and application)
- CORS configuration is environment-specific (different origins for staging and production)
- CORS configuration is reviewed as part of security reviews
The Principle of Least Privilege
Apply the same principle to CORS that you apply to everything else in security: only allow what you need.
- Don't allow all origins when you know exactly which origins need access.
- Don't allow all methods when your API only uses GET and POST.
- Don't allow all headers when your client only sends
AuthorizationandContent-Type. - Don't expose all response headers when your client only reads
X-Request-ID. - Don't enable credentials when your cross-origin endpoint doesn't use cookies.
- Don't set
Access-Control-Max-Ageto a year. A day or an hour is usually enough.
Every "allow" in your CORS configuration is an explicit decision to relax the Same-Origin Policy. Make each one deliberately. When you're not sure whether to allow something, don't. You'll find out quickly if it was needed, and it's much better to start restrictive and open up than to start permissive and try to lock down later.
# Principle of least privilege applied to CORS:
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Expose-Headers: X-Request-ID
Access-Control-Max-Age: 3600
Vary: Origin
# NOT this:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: *
Access-Control-Allow-Headers: *
Access-Control-Expose-Headers: *
Access-Control-Max-Age: 31536000
The first configuration says "I've thought about what my frontend needs and allowed exactly that." The second says "I was tired and wanted to go home." The first one is correct. The second one will be the subject of a security incident report at some point. Not today, maybe not tomorrow, but eventually.