CORS, What It Actually Protects
TL;DR — CORS does NOT secure your API. It’s a browser-side mechanism that controls which origins can read responses from cross-origin requests. Your API authentication still has to do the actual work. Set
Access-Control-Allow-Originto specific origins only (not*) when credentials are involved.
After API keys vs OAuth, one of the most misunderstood security topics. CORS is a browser feature; many developers think it’s a server security control. It isn’t.
Same-origin policy first
Browsers prevent JavaScript on https://attacker.com from reading responses from https://yourbank.com. The “same-origin policy” (SOP) is the foundation. Origin = scheme + host + port.
Without SOP, every site you visit could read every other site you’re logged into. SOP is what makes the modern web’s cookie-auth scheme work at all.
CORS is the opt-in mechanism for “yes, this OTHER origin can read my responses.”
What CORS actually does
A cross-origin fetch:
// Running on https://app.example.com
fetch('https://api.example.com/me', { credentials: 'include' })
The browser:
- Sends the request (yes, the server receives it)
- Reads the response
- Checks: did the server send
Access-Control-Allow-Origin: https://app.example.com? - If yes, gives the response to JavaScript
- If no, throws an error in the JS code (the response was received but blocked)
Critical: the server receives the request and processes it regardless of CORS. CORS doesn’t stop the request from being made; it stops JavaScript from reading the response.
For state-changing requests (POST, PUT, DELETE), preflight comes first.
Preflight requests
Browser sees a cross-origin non-simple request (POST with JSON, custom header, etc.)
↓
Browser sends OPTIONS preflight first:
OPTIONS /api/orders
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
↓
Server responds:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, GET, PUT, DELETE
Access-Control-Allow-Headers: content-type
Access-Control-Max-Age: 600
↓
If allowed, browser sends actual POST
The preflight asks “is this cross-origin request allowed?” Server says yes or no. Only after yes does the real request go.
For state-changing requests, preflight DOES prevent the request from happening (if denied). This is the one case where CORS actually blocks the action.
What CORS does NOT protect
CORS does NOT protect:
- Public APIs without credentials. Anyone can fetch them from anywhere. CORS allows the JS context to read; without credentials, no auth check needed.
- Server-to-server calls. Backends don’t enforce CORS. A Python script can fetch your API regardless.
- Mobile apps. No browser; no CORS enforcement.
- CSRF via simple requests. Simple POST with form-encoded body doesn’t preflight. CSRF is a separate problem; covered Wednesday.
CORS is browser-only, response-only, JavaScript-only.
Configuration
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, Authorization
Access-Control-Expose-Headers: X-Total-Count, X-RateLimit-Remaining
Access-Control-Max-Age: 600
Headers explained:
Allow-Origin: which origin can read responses. Single origin or*.Allow-Credentials: true: cookies/auth headers included. Required for credentialed requests.Allow-Methods: methods preflight allows.Allow-Headers: headers the request may include.Expose-Headers: response headers JS can read (other than safelist).Max-Age: how long browser caches the preflight response.
Two important gotchas:
* + credentials is rejected by browsers. You can’t allow any origin AND credentials. Origin must be specific.
// BAD — browser rejects
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
// OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
For credentialed cross-origin requests, echo the specific origin (after validation):
allowed := map[string]bool{
"https://app.example.com": true,
"https://admin.example.com": true,
}
origin := r.Header.Get("Origin")
if allowed[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Vary", "Origin")
}
The Vary: Origin is important — without it, caches may return the wrong CORS headers.
Wildcard subdomains
Browsers don’t support *.example.com in Allow-Origin. To allow many subdomains, echo the origin after regex-validation:
import "regexp"
allowedRe := regexp.MustCompile(`^https://[a-z0-9-]+\.example\.com$`)
origin := r.Header.Get("Origin")
if allowedRe.MatchString(origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
Always validate via allow-list; never trust the Origin header.
Common Pitfalls
Believing CORS secures your API. It doesn’t. Auth does.
Allow-Origin: * with credentials. Browser rejects.
Allow-Origin: <reflected origin from any header>. Allows any origin including attacker.com. Validate against allow-list.
Missing Vary: Origin. Caches misbehave.
Allowing preflight from anywhere. “We’ll just allow all origins for OPTIONS.” That’s the wrong layer; specific origins are required.
Forgetting non-browser callers. Your API is callable from curl, scripts, mobile apps regardless of CORS config.
CORS in middleware ordering wrong. Auth before CORS = OPTIONS preflight rejected by auth. CORS before auth = OPTIONS preflight responds 204 without auth.
Wrapping Up
CORS = browser opt-in for cross-origin response access. Not server security. Configure specifically; validate origins; allow credentials only for known origins. Wednesday: CSRF defense — the threat CORS doesn’t address.