Access-Control-Allow-Origin values from a validated allowlist -- never reflect the Origin header blindly or use * with credentials.Access-Control-Allow-Origin: https://your-frontend.com with Vary: Origin on every CORS response.Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true -- browsers silently reject this, and reflecting any Origin creates a universal CORS bypass vulnerability.Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true -- browsers reject this combination [src1]Origin header directly into Access-Control-Allow-Origin without validating against an allowlist [src3]null origin -- sandboxed iframes and data: URIs can forge null origins [src3]| Header | Purpose | Values | Security Risk if Wrong |
|---|---|---|---|
Access-Control-Allow-Origin | Which origin(s) may read the response | Exact origin, * (public), or omit | Reflecting any origin = universal CORS bypass |
Access-Control-Allow-Methods | Allowed HTTP methods for actual request | GET, POST, PUT, DELETE | Overly permissive methods expand attack surface |
Access-Control-Allow-Headers | Custom headers the client may send | Content-Type, Authorization | Allowing * with credentials is rejected |
Access-Control-Allow-Credentials | Permits cookies/auth in cross-origin requests | true or omit | Must pair with explicit origin, never * |
Access-Control-Expose-Headers | Response headers client JS can read | X-Request-Id, X-Total-Count | Omitting hides custom headers from fetch() |
Access-Control-Max-Age | Seconds to cache preflight results | 600 to 86400 | Too low = excessive preflights; too high = stale policy |
Vary: Origin | Tells caches response differs by Origin | Always include when ACAO is not * | Omitting causes caches to serve wrong CORS headers |
Access-Control-Request-Method | (Request) Method browser plans to use | Sent automatically in preflight | N/A (read-only) |
Access-Control-Request-Headers | (Request) Custom headers browser will send | Sent automatically in preflight | N/A (read-only) |
START: Does your API need cross-origin access?
├── NO → Do not set any CORS headers (same-origin policy protects you)
├── YES ↓
│ ├── Does the request carry cookies or Authorization headers?
│ │ ├── YES (credentialed) → Set ACAO to exact origin from allowlist + Credentials: true + Vary: Origin
│ │ └── NO (public) ↓
│ │ ├── Fully public API? → Access-Control-Allow-Origin: *
│ │ └── Restricted public → Set specific origin(s) from allowlist
│ ├── Uses non-simple methods (PUT, DELETE, PATCH)?
│ │ ├── YES → Handle OPTIONS preflight: return 200/204 with Allow-Methods, Allow-Headers, Max-Age
│ │ └── NO → No preflight needed, set ACAO on response
│ └── Multiple frontends need access?
│ ├── YES → Validate Origin against allowlist at runtime + Vary: Origin
│ └── NO → Hardcode the single allowed origin
Determine which origins need access, whether requests carry credentials, and which HTTP methods and custom headers are needed. [src2]
Questions to answer:
- Frontend origin(s): https://app.example.com, https://staging.example.com
- Credentials needed? Yes (session cookies) / No (public API)
- Methods needed: GET, POST, PUT, DELETE
- Custom headers: Authorization, Content-Type, X-Request-Id
Verify: Check browser DevTools > Network tab for blocked CORS requests (they appear as errors in the Console).
Never reflect the Origin header blindly. Validate against an allowlist and set appropriate response headers. [src3]
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://staging.example.com'
]);
function getCorsOrigin(requestOrigin) {
if (ALLOWED_ORIGINS.has(requestOrigin)) {
return requestOrigin;
}
return null;
}
Verify: curl -H "Origin: https://evil.com" -I https://your-api.com/data -- should NOT return Access-Control-Allow-Origin: https://evil.com
Browsers send a preflight OPTIONS request before any non-simple cross-origin request. Your server must respond with correct CORS headers. [src1]
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: 600
Vary: Origin
Verify: curl -X OPTIONS -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: PUT" -I https://your-api.com/data
Every cross-origin response (not just preflight) must include the Access-Control-Allow-Origin header. [src1]
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-Id
Vary: Origin
Content-Type: application/json
Verify: In browser DevTools > Network, confirm response includes Access-Control-Allow-Origin matching the requesting origin.
Automated tests should verify CORS headers for allowed origins, blocked origins, and preflight behavior. [src7]
# Test allowed origin
curl -sI -H "Origin: https://app.example.com" https://your-api.com/data \
| grep -i access-control
# Test blocked origin
curl -sI -H "Origin: https://evil.com" https://your-api.com/data \
| grep -i access-control
# Test preflight
curl -sI -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: DELETE" \
https://your-api.com/data | grep -i access-control
Verify: Allowed origin returns correct ACAO; blocked origin returns no ACAO; preflight returns Allow-Methods.
const express = require('express'); // ^4.18.0
const cors = require('cors'); // ^2.8.5
const ALLOWED_ORIGINS = [
'https://app.example.com',
'https://staging.example.com'
];
const app = express();
app.use(cors({
origin: (origin, callback) => {
if (!origin) return callback(null, true);
if (ALLOWED_ORIGINS.includes(origin)) return callback(null, origin);
return callback(new Error('Not allowed by CORS'));
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['X-Request-Id'],
maxAge: 600
}));
# settings.py -- pip install django-cors-headers
INSTALLED_APPS = ['corsheaders', ...]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', # Before CommonMiddleware
'django.middleware.common.CommonMiddleware',
...
]
CORS_ALLOWED_ORIGINS = [
'https://app.example.com',
'https://staging.example.com',
]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_METHODS = ['GET','POST','PUT','PATCH','DELETE','OPTIONS']
CORS_ALLOW_HEADERS = ['accept','authorization','content-type']
CORS_PREFLIGHT_MAX_AGE = 600
package main
import (
"net/http"
"github.com/rs/cors" // v1.11+
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/data", handler)
c := cors.New(cors.Options{
AllowedOrigins: []string{"https://app.example.com"},
AllowedMethods: []string{"GET","POST","PUT","DELETE"},
AllowedHeaders: []string{"Content-Type","Authorization"},
ExposedHeaders: []string{"X-Request-Id"},
AllowCredentials: true,
MaxAge: 600,
})
http.ListenAndServe(":8080", c.Handler(mux))
}
@Configuration
public class CorsConfig {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://app.example.com")
.allowedMethods("GET","POST","PUT","DELETE")
.allowedHeaders("Content-Type","Authorization")
.exposedHeaders("X-Request-Id")
.allowCredentials(true)
.maxAge(600);
}
};
}
}
// BAD -- reflects any origin, creating a universal CORS bypass
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
next();
});
// GOOD -- only allows known origins
const ALLOWED = new Set(['https://app.example.com']);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (ALLOWED.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin');
}
next();
});
// BAD -- browsers reject * with credentials
app.use(cors({ origin: '*', credentials: true }));
// GOOD -- specific origin allows credentialed requests
app.use(cors({ origin: 'https://app.example.com', credentials: true }));
# BAD -- null origin can be forged by sandboxed iframes
Access-Control-Allow-Origin: null
Access-Control-Allow-Credentials: true
# GOOD -- explicit HTTPS origin only
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
// BAD -- CDN caches ACAO for one origin, serves to all
res.setHeader('Access-Control-Allow-Origin', validatedOrigin);
// Missing: Vary: Origin
// GOOD -- tells caches response varies by Origin
res.setHeader('Access-Control-Allow-Origin', validatedOrigin);
res.setHeader('Vary', 'Origin');
// BAD -- matches evil-example.com or example.com.evil.com
const isAllowed = /example\.com/.test(origin);
// GOOD -- exact match from Set
const ALLOWED = new Set(['https://app.example.com']);
const isAllowed = ALLOWED.has(origin);
Vary: Origin causes CDNs to serve cached CORS headers for the wrong origin. Fix: Always set Vary: Origin when ACAO is not *. [src1]app.use(cors(...)) before routes. In Django, place CorsMiddleware before CommonMiddleware. [src6].endsWith('example.com') also matches evilexample.com. Fix: Use a Set of exact allowed origins, or parse URLs and compare hostnames exactly. [src3]X-Request-Id are hidden. Fix: Add them to Access-Control-Expose-Headers. [src1]Access-Control-Allow-Headers: * is rejected when credentials mode is include. Fix: List specific headers explicitly. [src4]http:// origins enables MITM to inject CORS requests. Fix: Only trust HTTPS origins. [src3]# Check CORS headers for a specific origin
curl -sI -H "Origin: https://app.example.com" https://your-api.com/endpoint \
| grep -i "access-control\|vary"
# Test preflight (OPTIONS) request
curl -sI -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: Authorization, Content-Type" \
https://your-api.com/endpoint
# Verify blocked origin returns no ACAO
curl -sI -H "Origin: https://evil.com" https://your-api.com/endpoint \
| grep -i access-control
# Check if null origin is allowed (should fail)
curl -sI -H "Origin: null" https://your-api.com/endpoint \
| grep -i access-control
# Browser: DevTools > Console > filter "CORS"
| Standard/Feature | Status | Browser Support | Key Change |
|---|---|---|---|
| CORS (Fetch spec) | Living Standard | All modern browsers | Core CORS mechanism: preflight, ACAO, credentials |
| Private Network Access | Draft | Chrome 104+ | Preflight required for public-to-private network requests |
| Wildcard * in ACAH/ACAM | Fetch spec | Chrome 63+, Firefox 69+, Safari 15.4+ | Wildcard allowed for non-credentialed requests |
| Access-Control-Max-Age default | Fetch spec | Varies | Chrome: 5s default, Firefox: 24h, Safari: 5s |
| Preflight caching cap | Fetch spec | All modern | Chrome: 7200s max, Firefox: 86400s max |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Frontend and API are on different origins | Frontend and API share the same origin | No CORS needed -- same-origin policy allows requests |
| Third-party integrations need your public API | Protecting data from cross-origin reads | Server-side authentication/authorization |
| Mobile web view hitting your API on a different origin | Server-to-server communication (no browser) | API keys or mTLS instead of CORS |
| CDN serves fonts/images to pages on different origins | You want to restrict which servers call your API | CORS does NOT restrict non-browser clients |
Origin header can be spoofed by non-browser clients; always pair CORS with proper authentication on every endpointAccess-Control-Max-Age has different maximum caps: Chrome 7200s (2h), Firefox 86400s (24h) -- higher values are silently clampedOrigin header and does not strip CORS response headers -- always use Vary: Originnull origin is sent by sandboxed iframes, data: URIs, and file:// pages -- never allow it in your allowlist