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:

  1. You're logged into https://mybank.com. You have a session cookie.
  2. You visit https://evil.example.com.
  3. Evil's JavaScript makes a fetch() to https://mybank.com/api/accounts.
  4. The browser attaches your mybank.com session cookie automatically.
  5. The bank's API returns your account data.
  6. 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:

  1. User is logged into https://myapp.com
  2. User visits https://evil.example.com
  3. Evil sends a request to https://api.myapp.com/user/data
  4. The request includes Origin: https://evil.example.com
  5. Your server echoes back Access-Control-Allow-Origin: https://evil.example.com and Access-Control-Allow-Credentials: true
  6. The browser says "the server explicitly allowed this origin with credentials, so I'll attach the cookies and let JavaScript read the response"
  7. 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 (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.

CSRFCORS
AttackMake the server do somethingRead what the server returns
ConcernState-changing requests (POST, PUT, DELETE)Response data
DefenseCSRF tokens, SameSite cookiesCORS headers
Key insightThe request itself is the attackThe 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:

  1. Require a Content-Type: application/json header (which triggers a preflight)
  2. 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 Origin header back as Access-Control-Allow-Origin
  • Validate Origin against an explicit allowlist of known origins
  • Use exact string matching, not regex (or use very carefully anchored regex)
  • Don't include null as an allowed origin (sandboxed iframes and redirects send Origin: null, and allowing it defeats the purpose)
  • Don't include localhost or development origins in production configs

Credentials

  • Only set Access-Control-Allow-Credentials: true if 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: Origin on every response that dynamically sets Access-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 Authorization and Content-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-Age to 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.