CORS Configuration: Cross-Origin Resource Sharing Guide
How do I configure CORS correctly?
TL;DR
- Bottom line: Configure CORS by setting explicit
Access-Control-Allow-Originvalues from a validated allowlist -- never reflect theOriginheader blindly or use*with credentials. - Key tool/command:
Access-Control-Allow-Origin: https://your-frontend.comwithVary: Originon every CORS response. - Watch out for: Using
Access-Control-Allow-Origin: *together withAccess-Control-Allow-Credentials: true-- browsers silently reject this, and reflecting any Origin creates a universal CORS bypass vulnerability. - Works with: All modern browsers (Chrome 4+, Firefox 3.5+, Safari 4+, Edge 12+). CORS is defined by the WHATWG Fetch specification.
Constraints
- NEVER use
Access-Control-Allow-Origin: *withAccess-Control-Allow-Credentials: true-- browsers reject this combination [src1] - NEVER reflect the
Originheader directly intoAccess-Control-Allow-Originwithout validating against an allowlist [src3] - NEVER whitelist the
nullorigin -- sandboxed iframes anddata:URIs can forge null origins [src3] - Preflight responses (OPTIONS) MUST return 200/204 with the correct CORS headers or the actual request will be blocked [src4]
- CORS is enforced by the browser only -- server-side callers bypass CORS entirely, so CORS is NOT a substitute for authentication/authorization [src5]
Quick Reference
| 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) |
Decision Tree
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
Step-by-Step Guide
1. Identify your cross-origin requirements
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).
2. Implement origin validation on the server
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
3. Handle preflight (OPTIONS) requests
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
4. Set headers on actual responses
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.
5. Test with real browsers and curl
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.
Code Examples
Express.js (Node.js): Secure CORS with allowlist
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
}));
Django (Python): django-cors-headers
# 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
Go: rs/cors middleware
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))
}
Spring Boot (Java): Global CORS config
@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);
}
};
}
}
Anti-Patterns
Wrong: Reflecting Origin header without validation
// 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();
});
Correct: Validate Origin against allowlist
// 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();
});
Wrong: Wildcard origin with credentials
// BAD -- browsers reject * with credentials
app.use(cors({ origin: '*', credentials: true }));
Correct: Explicit origin with credentials
// GOOD -- specific origin allows credentialed requests
app.use(cors({ origin: 'https://app.example.com', credentials: true }));
Wrong: Allowing the null origin
# BAD -- null origin can be forged by sandboxed iframes
Access-Control-Allow-Origin: null
Access-Control-Allow-Credentials: true
Correct: Only allow real HTTPS origins
# GOOD -- explicit HTTPS origin only
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Wrong: Missing Vary: Origin header
// BAD -- CDN caches ACAO for one origin, serves to all
res.setHeader('Access-Control-Allow-Origin', validatedOrigin);
// Missing: Vary: Origin
Correct: Always include Vary: Origin
// GOOD -- tells caches response varies by Origin
res.setHeader('Access-Control-Allow-Origin', validatedOrigin);
res.setHeader('Vary', 'Origin');
Wrong: Unanchored regex origin matching
// BAD -- matches evil-example.com or example.com.evil.com
const isAllowed = /example\.com/.test(origin);
Correct: Exact match or anchored regex
// GOOD -- exact match from Set
const ALLOWED = new Set(['https://app.example.com']);
const isAllowed = ALLOWED.has(origin);
Common Pitfalls
- Missing Vary: Origin: Dynamically setting ACAO without
Vary: Origincauses CDNs to serve cached CORS headers for the wrong origin. Fix: Always setVary: Originwhen ACAO is not*. [src1] - Preflight not handled: Server returns 404/405 for OPTIONS, blocking all non-simple cross-origin requests. Fix: Ensure your framework handles OPTIONS routes; most CORS middleware does this automatically. [src4]
- CORS middleware ordering: Middleware placed after route handlers means responses are sent without CORS headers. Fix: In Express, call
app.use(cors(...))before routes. In Django, placeCorsMiddlewarebeforeCommonMiddleware. [src6] - Subdomain matching pitfalls: Using
.endsWith('example.com')also matchesevilexample.com. Fix: Use a Set of exact allowed origins, or parse URLs and compare hostnames exactly. [src3] - Forgetting exposed headers: Browsers only expose simple response headers to JS by default. Custom headers like
X-Request-Idare hidden. Fix: Add them toAccess-Control-Expose-Headers. [src1] - Wildcard ACAH with credentials:
Access-Control-Allow-Headers: *is rejected when credentials mode isinclude. Fix: List specific headers explicitly. [src4] - HTTP origins from HTTPS API: Trusting
http://origins enables MITM to inject CORS requests. Fix: Only trust HTTPS origins. [src3]
Diagnostic Commands
# 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"
Version History & Compatibility
| 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 |
When to Use / When Not to Use
| 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 |
Important Caveats
- CORS is a browser-enforced mechanism only -- any non-browser HTTP client (curl, Postman, server-side code) completely ignores CORS headers
- The
Originheader can be spoofed by non-browser clients; always pair CORS with proper authentication on every endpoint Access-Control-Max-Agehas different maximum caps: Chrome 7200s (2h), Firefox 86400s (24h) -- higher values are silently clamped- Private Network Access (PNA) is Chrome-only as of 2026 and may break localhost development setups
- When behind a CDN or reverse proxy, ensure the proxy forwards the
Originheader and does not strip CORS response headers -- always useVary: Origin - The
nullorigin is sent by sandboxed iframes, data: URIs, and file:// pages -- never allow it in your allowlist