| |

REST API 15 ๐ŸŒ CORS (Cross-Origin Resource Sharing) Configuration

โฑ๏ธ Estimated read: 12 min | |

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

HeaderPurpose
Access-Control-Allow-OriginThe allowed origin (or *)
Access-Control-Allow-MethodsAllowed HTTP methods
Access-Control-Allow-HeadersAllowed request headers
Access-Control-Allow-CredentialsAllow cookies/auth
Access-Control-Max-AgePreflight cache duration
VaryOrigin (when dynamic)

Credentials Rules

RuleConsequence
* + credentialsBrowser blocks
Exact origin + credentialsAllowed
No credentials headerCookies not sent

Preflight Triggers

ConditionPreflight?
GET/HEAD/POST with simple headersNo
Content-Type: application/jsonYes
Authorization headerYes
PUT/DELETEYes

Express cors Options

OptionPurpose
originAllowlist or function
credentialsAccess-Control-Allow-Credentials
methodsAllowed methods
allowedHeadersAllowed 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

PitfallWhy It HappensFix
CORS error but curl worksBrowser enforcementCheck the browser console
* with credentialsBrowser blocksUse exact origin
Preflight failsNo OPTIONS handlerHandle OPTIONS
Missing Vary: OriginCache poisoningAdd the header
Authorization not allowedNot in allowedHeadersAdd 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

ItemValue
CORS enforcementBrowser
Access-Control-Allow-OriginExact origin or *
Credentials + *Forbidden by browser
PreflightOPTIONS request
Preflight headersMethods, headers, origin
Vary: OriginRequired for dynamic origin
Expresscors middleware
Nginxadd_header + OPTIONS block

Key takeaways:

  • CORS is enforced by the browser, not the server. A curl request 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 validated Origin value .
  • Preflighted requests need an OPTIONS handler. A request with Authorization or Content-Type: application/json triggers the preflight, and the server must answer with the allowed methods and headers .
  • Vary: Origin prevents cache poisoning. When the Access-Control-Allow-Origin value depends on the request, the response must declare that dependency .
  • The cors middleware handles the common case. For Express, the cors package with an allowlist function is the safe configuration. For Nginx, the map directive 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!