REST API 15 ๐ CORS (Cross-Origin Resource Sharing) Configuration
CORS is not a security feature. It is a browser-enforced relaxation of the same-origin policy, and the server’s only job is to return the headers that tell the browser whether a cross-origin response is allowed to be read . The browser sends the request regardless; if the response lacks the correct Access-Control-Allow-Origin header, the browser blocks the JavaScript from reading it. The server logs show a successful 200 OK while the browser console shows a CORS error, and this mismatch is the source of most confusion.
The mechanism has two paths. A simple requestโGET, HEAD, or POST with only safelisted headers and a safelisted Content-Typeโis sent directly, and the browser checks the response headers afterward. A preflighted requestโanything with a custom header like Authorization, a method like PUT or DELETE, or a non-simple content typeโtriggers an automatic OPTIONS request before the real one, asking the server whether the actual request is permitted . The server must answer the OPTIONS with the allowed methods, headers, and origin, and the browser only sends the real request if the answer is affirmative.
Key point: CORS is enforced by the browser, not the server. The server must return Access-Control-Allow-Origin matching the request’s Origin (or * for public APIs). If credentials: true is used, the origin cannot be * and must be an explicit domain . Preflighted requests require the server to handle OPTIONS with the correct Access-Control-Allow-Methods and Access-Control-Allow-Headers.
Why CORS configuration is a server-side decision
The same-origin policy problem. A browser will not let JavaScript on app.example.com read a response from api.other.com by default. This prevents a malicious site from making authenticated requests to a user’s bank and reading the balance. CORS is the controlled exception: the API tells the browser which origins are allowed to read its responses . The policy is enforced at the browser, which means a curl request or a Postman request ignores CORS entirely .
The wildcard problem. Access-Control-Allow-Origin: * allows any origin to read the response. This is acceptable for a public, credential-free API. It is a security failure for a private API, because it lets any website read the response on behalf of a logged-in user if the API also accepts cookies or Authorization headers . The browser actually forbids the combination of * and Access-Control-Allow-Credentials: true, which is why the dangerous misconfiguration reflects the Origin header back instead .
The credentials problem. If the request includes cookies or Authorization headers, the browser requires Access-Control-Allow-Credentials: true on the response, and the Access-Control-Allow-Origin must be the exact origin, not * . A server that reflects every origin with credentials enabled tells the browser that every website is trusted, which is a textbook cross-site data-theft setup .
The preflight problem. A request with Content-Type: application/json and an Authorization header triggers an OPTIONS preflight. The server must respond with the allowed origin, methods, and headers, or the browser blocks the real request before it is sent . A server that handles the GET but not the OPTIONS passes a manual curl test and fails in the browser.
a. The response headers and the origin decision
The Access-Control-Allow-Origin header is the core of the configuration. It must contain either the exact origin of the requesting site or the * wildcard. For a credentialed API, the server must read the request’s Origin header, validate it against an allowlist, and echo the validated value back, along with Vary: Origin so that caches do not serve one origin’s response to another .
// Express with the cors middleware
// The allowlist is the exact set of trusted origins
const allowedOrigins = new Set([
'https://app.example.com',
'https://admin.example.com',
])
app.use(cors({
// The origin function receives the request's Origin header
// It returns true if the origin is in the allowlist
origin: (origin, callback) => {
// Requests without an Origin header (same-origin, curl) are allowed
if (!origin || allowedOrigins.has(origin)) {
return callback(null, true)
}
// Any other origin is rejected
return callback(new Error('Origin not allowed by CORS'))
},
// Credentials are allowed because the allowlist is exact
credentials: true,
// The methods the API actually supports
methods: ['GET', 'POST', 'PUT', 'DELETE'],
// The headers the browser may send
allowedHeaders: ['Content-Type', 'Authorization'],
}))
The origin function is the decision point. A truthy return value causes the middleware to set Access-Control-Allow-Origin to the request’s Origin. The credentials: true option adds Access-Control-Allow-Credentials: true. The methods and allowedHeaders options define what the preflight response will allow. The cors package handles the OPTIONS request automatically when it is installed as middleware .
The Vary: Origin header is not set by the cors package automatically in all configurations. It is required when the Access-Control-Allow-Origin value depends on the request’s Origin, because a shared cache that stores the response for one origin must not serve it to another . A server behind a CDN that omits Vary: Origin can serve a cached response with the wrong Access-Control-Allow-Origin to a different origin.
b. The reverse proxy configuration
When the API is behind Nginx, the CORS headers can be set at the proxy instead of in the application. This is common when the application is a third-party service that does not support CORS, or when the proxy is the only component that knows the public origin.
# Nginx configuration for a single trusted origin
location /api/ {
# The exact origin that is allowed
add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
# The methods the API supports
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
# The headers the browser may send
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
# The Vary header prevents cache poisoning
add_header 'Vary' 'Origin' always;
# The preflight request is answered directly
if ($request_method = 'OPTIONS') {
# The max age caches the preflight response
add_header 'Access-Control-Max-Age' 86400;
# The 204 response has no body
return 204;
}
# The actual request is proxied to the backend
proxy_pass http://backend:8080;
}
The always flag on add_header ensures the header is sent even on error responses (4xx and 5xx). Without it, Nginx only adds the header on 2xx and 3xx responses, and a 401 or 500 response would lack the CORS headers, causing the browser to block the error body from the JavaScript . The if ($request_method = 'OPTIONS') block answers the preflight without touching the backend, which is the correct behavior when the backend does not handle OPTIONS itself.
For multiple origins, the map directive is the safe pattern. A regex that matches example.com without anchoring can match example.com.evil.com, so the regex must be anchored to the full origin string .
# The map is defined in the http block
http {
map $http_origin $cors_origin {
# The regex is anchored to the full origin
# It matches only the exact domains, not subdomains of an attacker
~^https://(app\.example\.com|admin\.example\.com)$ $http_origin;
# The default is empty, which sends no CORS header
default "";
}
server {
location /api/ {
# The header is set to the validated origin
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Vary' 'Origin' always;
}
}
}
The $cors_origin variable is empty for a disallowed origin, and an empty Access-Control-Allow-Origin header is treated as absent by the browser. The request is blocked, and the server has not reflected an untrusted origin .
c. The credentials and preflight constraints
The Access-Control-Allow-Credentials: true header is required when the request includes cookies or Authorization headers, and the Access-Control-Allow-Origin must be an exact origin, not * . A preflight request itself never includes credentials, and the response must include Access-Control-Allow-Credentials: true for the real request to be allowed to send them .
// The frontend must set credentials: 'include' for cookies
fetch('https://api.example.com/data', {
method: 'GET',
// The browser will send cookies and expect the credentials header
credentials: 'include',
})
The credentials: 'include' on the frontend and credentials: true on the backend are a pair. If either is missing, the browser blocks the response or the cookie is not sent. The cors middleware’s credentials option sets the response header, and the frontend’s credentials option controls whether the browser includes the cookies .
The preflight response must also include the allowed headers and methods. A common failure is a server that sets Access-Control-Allow-Origin but not Access-Control-Allow-Headers, so the browser rejects the preflight for a request with Content-Type: application/json because the Content-Type header is not allowed . The cors middleware handles this automatically when configured, but a hand-written Nginx configuration must list every header the browser sends.
The Access-Control-Max-Age header caches the preflight response for a number of seconds, which reduces the number of OPTIONS requests the browser sends. A value of 86400 (24 hours) is common, but the browser caps the value at its own maximum, and the cache is per-origin and per-URL .
Complete Example Session
The following session configures CORS in an Express application with an allowlist, credentials, and a preflight test, then verifies it with curl.
// ============================================
// PART 1: THE EXPRESS SETUP
// ============================================
const express = require('express')
const cors = require('cors')
const app = express()
// The allowlist is the exact set of trusted origins
const allowedOrigins = new Set([
'https://app.example.com',
'https://admin.example.com',
])
// The cors middleware is installed with the allowlist
app.use(cors({
origin: (origin, callback) => {
// No Origin header (same-origin, curl) is allowed
if (!origin || allowedOrigins.has(origin)) {
return callback(null, true)
}
// Any other origin is rejected
return callback(new Error('Origin not allowed by CORS'))
},
// Credentials are allowed because the allowlist is exact
credentials: true,
// The API's methods and headers
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}))
// The API route
app.get('/api/data', (req, res) => {
res.json({ message: 'hello' })
})
app.listen(3000)
# ============================================
# PART 2: THE PREFLIGHT REQUEST
# ============================================
# The browser sends this automatically before the real request
# The Origin header is the calling site
curl -i -X OPTIONS http://localhost:3000/api/data \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: Authorization"
# The response includes the CORS headers:
# 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
# ============================================
# PART 3: THE DISALLOWED ORIGIN
# ============================================
# An origin not in the allowlist is rejected
curl -i -X OPTIONS http://localhost:3000/api/data \
-H "Origin: https://evil.example.com" \
-H "Access-Control-Request-Method: GET"
# The response lacks Access-Control-Allow-Origin
# The browser blocks the real request
The three parts cover the Express setup with the allowlist, the preflight request with the allowed origin, and the preflight with the disallowed origin. What the session demonstrates is that the server’s decision is the Access-Control-Allow-Origin value, and the browser enforces it by blocking the response when the value does not match the origin.
Quick Reference
Response Headers
| Header | Purpose |
|---|---|
Access-Control-Allow-Origin | The allowed origin (or *) |
Access-Control-Allow-Methods | Allowed HTTP methods |
Access-Control-Allow-Headers | Allowed request headers |
Access-Control-Allow-Credentials | Allow cookies/auth |
Access-Control-Max-Age | Preflight cache duration |
Vary | Origin (when dynamic) |
Credentials Rules
| Rule | Consequence |
|---|---|
* + credentials | Browser blocks |
| Exact origin + credentials | Allowed |
| No credentials header | Cookies not sent |
Preflight Triggers
| Condition | Preflight? |
|---|---|
GET/HEAD/POST with simple headers | No |
Content-Type: application/json | Yes |
Authorization header | Yes |
PUT/DELETE | Yes |
Express cors Options
| Option | Purpose |
|---|---|
origin | Allowlist or function |
credentials | Access-Control-Allow-Credentials |
methods | Allowed methods |
allowedHeaders | Allowed headers |
Best Practices
โ Do This:
// Use an exact allowlist
const allowed = new Set(['https://app.example.com'])
origin: (o, cb) => cb(null, !o || allowed.has(o))
// Enable credentials only with an exact origin
credentials: true
// Set Vary: Origin when the header is dynamic
res.setHeader('Vary', 'Origin')
โ Don’t Do This:
// Reflect every origin with credentials
origin: (o, cb) => cb(null, true), credentials: true
// Use * with credentials
origin: '*', credentials: true // browser blocks
// Use a regex that matches example.com.evil.com
/example\.com$/ // missing anchor
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
CORS error but curl works | Browser enforcement | Check the browser console |
* with credentials | Browser blocks | Use exact origin |
| Preflight fails | No OPTIONS handler | Handle OPTIONS |
Missing Vary: Origin | Cache poisoning | Add the header |
Authorization not allowed | Not in allowedHeaders | Add it |
Real-World Examples
1. Express allowlist
origin: (o, cb) => cb(null, !o || allowed.has(o))
2. Express credentials
credentials: true
3. Nginx single origin
add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
4. Nginx preflight
if ($request_method = 'OPTIONS') { return 204; }
5. Nginx Vary
add_header 'Vary' 'Origin' always;
6. Frontend credentials
fetch(url, { credentials: 'include' })
7. Preflight cache
add_header 'Access-Control-Max-Age' 86400;
8. Allowlist with Set
const allowed = new Set(['https://app.example.com'])
9. Reject disallowed origin
callback(new Error('Origin not allowed'))
10. Test with curl
curl -i -X OPTIONS -H "Origin: https://app.example.com" http://localhost:3000/api
Visual
Simple vs Preflighted
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ SIMPLE REQUEST โ
โ GET, simple headers โ
โ โ sent directly โ
โ โ browser checks response headers โ
โ โ
โ PREFLIGHTED REQUEST โ
โ PUT, Authorization, JSON โ
โ โ OPTIONS sent first โ
โ โ server must allow โ
โ โ real request sent โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Origin Decision
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Request Origin: https://app.example.com โ
โ โ โ
โ โผ โ
โ In allowlist? โ
โ โโ YES โ ACAO: https://app.example.com โ
โ โโ NO โ no ACAO โ browser blocks โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Credentials Constraint
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ credentials: true โ
โ โ โ
โ โผ โ
โ ACAO must be exact origin โ
โ (not *) โ
โ โ โ
โ โผ โ
โ ACAO: https://app.example.com โ
โ
โ ACAO: * โ browser blocks โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| CORS enforcement | Browser |
Access-Control-Allow-Origin | Exact origin or * |
Credentials + * | Forbidden by browser |
| Preflight | OPTIONS request |
| Preflight headers | Methods, headers, origin |
Vary: Origin | Required for dynamic origin |
| Express | cors middleware |
| Nginx | add_header + OPTIONS block |
Key takeaways:
- CORS is enforced by the browser, not the server. A
curlrequest ignores CORS; the browser blocks the JavaScript from reading the response if the headers are missing or wrong . - The origin must match exactly or be
*. For a credentialed API,*is forbidden, and the server must echo the validatedOriginvalue . - Preflighted requests need an
OPTIONShandler. A request withAuthorizationorContent-Type: application/jsontriggers the preflight, and the server must answer with the allowed methods and headers . Vary: Originprevents cache poisoning. When theAccess-Control-Allow-Originvalue depends on the request, the response must declare that dependency .- The
corsmiddleware handles the common case. For Express, thecorspackage with an allowlist function is the safe configuration. For Nginx, themapdirective with an anchored regex is the safe pattern . - A reflected origin with credentials is a data-theft setup. It tells the browser that every website is trusted, and the victim’s cookies are sent to the attacker’s origin .
Remember: CORS is not a server-side firewall. It is a browser-side permission slip that the server fills in. The server’s job is to decide which origins are allowed to read its responses and to return the headers that communicate that decision. The browser’s job is to enforce it. The three mistakes that cause most CORS failures are a missing Access-Control-Allow-Origin on the response, an OPTIONS request that the server does not handle, and a * wildcard combined with credentials: true. The fix is an exact allowlist, a preflight handler, and the Vary: Origin header.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!