BytePane

What Is CORS? Cross-Origin Resource Sharing Explained

Web Security18 min read

Key Takeaways

  • CORS is a browser mechanism, not a server security feature. curl and Postman never trigger CORS — only JavaScript running in a browser does.
  • The problem is reading the response, not sending the request. The HTTP request is sent regardless. CORS controls whether JavaScript can see the result.
  • Three parts of a URL define the origin: scheme (http/https), host (including subdomain), and port. Any difference = cross-origin.
  • Most API calls trigger a preflight (OPTIONS) request before the real request. If OPTIONS fails, the real request is never sent.
  • The fix lives on the server, not the client. CORS is configured via HTTP response headers the server adds.

The Myth: "CORS Is a Server Security Feature"

The most common misconception about CORS is that it protects your server from unauthorized requests. It does not. An HTTP request from JavaScript on evil.com to your API is sent to your server — your server receives it and responds. CORS is what happens next: the browser decides whether to expose that response to the JavaScript that asked for it.

This is why curl and Postman never encounter CORS errors. Those tools make direct HTTP requests and read the response — no browser involved, no CORS check. The security CORS provides is entirely on behalf of the user, not the server.

# What CORS actually protects against:

# 1. User visits bank.com → logs in → session cookie is set
# 2. User navigates to evil.com
# 3. evil.com JavaScript runs in browser:
#    fetch('https://bank.com/api/balance')
#    → Browser sends request WITH user's bank.com session cookie
#    → Bank server receives request, authenticates via cookie, returns balance
#    → Without CORS: evil.com reads { balance: 50000 } ← theft
#    → With CORS: browser checks bank.com's response headers
#      No Access-Control-Allow-Origin header → browser blocks JavaScript
#      evil.com cannot read the response

# What CORS does NOT protect against:
# curl -b 'session=abc123' https://bank.com/api/balance   ← CORS doesn't apply
# Requires authentication to stop, not CORS

CORS and authentication are complementary. CORS determines which browser origins can read responses; authentication (JWTs, session cookies, API keys) determines who is allowed to make requests at all. You need both. See the API Authentication Methods guide for how they work together.

What Is an Origin?

The concept of "origin" is defined in RFC 6454 (The Web Origin Concept, 2011). An origin is the three-part combination of scheme + host + port. All three must match exactly for two URLs to share the same origin.

# Reference origin: https://app.example.com

# Same origin ✓ — all three parts match
https://app.example.com/dashboard
https://app.example.com/api/users

# Cross-origin ✗ — different scheme
http://app.example.com/dashboard      # http ≠ https

# Cross-origin ✗ — different host (subdomain counts)
https://api.example.com/users         # api. ≠ app.
https://example.com/users             # no subdomain ≠ app.
https://www.example.com/users         # www. ≠ app.

# Cross-origin ✗ — different port
https://app.example.com:3000/api      # :3000 ≠ implicit :443

# JavaScript: check the current origin
console.log(window.location.origin)   // "https://app.example.com"

# In Node.js (no "same-origin" concept — all requests are trusted):
const url = new URL('https://api.example.com/data')
console.log(url.origin)               // "https://api.example.com"

The subdomain rule trips up developers building microservices. app.example.com and api.example.com are different origins even though they share the root domain. You cannot configure CORS at the DNS level — you configure it per server, per response.

The Same-Origin Policy: Why CORS Exists

The Same-Origin Policy (SOP) is a security constraint all browsers implement. Without it, any JavaScript running in a browser tab could read responses from any other website — including authenticated endpoints using your session cookies. The SOP is why a malicious site cannot silently read your email by issuing a fetch to https://mail.google.com/api/messages.

However, the SOP was designed before the modern web needed APIs. Single-page applications routinely need to call APIs on different subdomains or domains. CORS is the mechanism that lets servers opt in to specific cross-origin access — a controlled exception to the default deny.

Request typeSOP / CORS behaviorNotes
<img src> cross-originAllowedCannot read pixels without CORS (Canvas taint)
<script src> cross-originAllowedScript executes but JS cannot read its source
<link rel=stylesheet>AllowedCSS applied, but not readable via JS
fetch() / XMLHttpRequestBlocked by defaultRequires CORS headers on the server response
HTML form POST cross-originSent, response blockedForm can submit; JS cannot read the response
WebSocketCORS does not applyServer must validate Origin header manually

Simple Requests vs Preflighted Requests

The Fetch specification (WHATWG) divides cross-origin requests into two categories based on historical web compatibility. "Simple" requests can be sent directly because they are equivalent to what plain HTML forms could do before CORS existed. "Non-simple" requests require a preflight first.

# Simple request criteria (no preflight):
# ✓ Method: GET, HEAD, or POST
# ✓ Content-Type (for POST): application/x-www-form-urlencoded,
#                             multipart/form-data, or text/plain
# ✓ No custom headers (only CORS-safelisted: Accept, Accept-Language,
#                       Content-Language, Content-Type as above)

# Example: Simple GET — no preflight
fetch('https://api.example.com/public-data')
# Browser directly sends request, checks response for CORS header

# ─────────────────────────────────────────────────────────────
# Non-simple request triggers preflight:
# ✗ Any PUT, DELETE, PATCH, OPTIONS method
# ✗ POST with Content-Type: application/json
# ✗ Any custom header: Authorization, X-API-Key, X-Request-ID, etc.

# Example: Almost all real API calls are non-simple
fetch('https://api.example.com/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',   // ← triggers preflight
    'Authorization': 'Bearer eyJ...',      // ← triggers preflight
  },
  body: JSON.stringify({ name: 'Alice' }),
})

# Browser first sends:
# OPTIONS https://api.example.com/users
# Origin: https://app.example.com
# Access-Control-Request-Method: POST
# Access-Control-Request-Headers: Content-Type, Authorization

How a Preflight Works: Step by Step

# ── STEP 1: Browser sends OPTIONS preflight ──────────────────

OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

# ── STEP 2: Server must respond 200 or 204 ────────────────────

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400          # Preflight cached 24h (Chrome caps at 7200)
Vary: Origin                           # Critical for CDNs serving multiple origins

# If server returns 404 or 405 for OPTIONS:
# → Browser never sends the actual POST
# → You see "CORS error" even though it's really a missing OPTIONS handler

# ── STEP 3: Browser sends actual request ─────────────────────

POST /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGci...

{"name": "Alice", "email": "[email protected]"}

# ── STEP 4: Server responds WITH CORS header on actual response too ──

HTTP/1.1 201 Created
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
Content-Type: application/json

{"id": 42, "name": "Alice"}

# ── STEP 5: Browser checks header, exposes response to JS ─────

# fetch() promise resolves with the response object
# JavaScript can now call response.json(), read headers, etc.

Access-Control-Max-Age is worth setting. Chrome caches preflight responses for up to 7,200 seconds (2 hours); Firefox up to 86,400 seconds (24 hours). Without it, every single API call in a browser session fires an extra OPTIONS round trip — measurable latency on high-request-rate applications.

Every CORS Header Explained

HeaderSet onWhat it does
Access-Control-Allow-OriginAll responsesWhich origin can read the response. Use exact origin or * (no credentials)
Access-Control-Allow-MethodsPreflight onlyHTTP methods permitted from cross-origin (GET, POST, PUT, DELETE, OPTIONS)
Access-Control-Allow-HeadersPreflight onlyRequest headers the client is allowed to send (list all custom headers)
Access-Control-Allow-CredentialsAll responsesAllow cookies + auth headers. Requires exact origin (never *)
Access-Control-Max-AgePreflight onlySeconds to cache the preflight result. Reduces OPTIONS round trips
Access-Control-Expose-HeadersAll responsesCustom response headers JavaScript is allowed to read (by default, only 7 headers are exposed)
OriginRequest (browser)Set automatically by the browser. Cannot be spoofed by JavaScript
Vary: OriginAll responsesTells CDNs this response varies per origin. Critical when using origin allowlists

The Vary: Origin header deserves special attention. If your server returns different Access-Control-Allow-Origin values for different origins (using an allowlist), CDNs will cache one version and serve it for all origins — breaking CORS for everyone else. Vary: Origin tells Cloudflare, Fastly, and CloudFront to cache separately per origin.

Implementing CORS in Major Frameworks

Node.js / Express

// npm install cors
import express from 'express'
import cors from 'cors'

const app = express()

// Single origin (production)
app.use(cors({
  origin: 'https://app.example.com',
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400,
}))

// Dynamic allowlist (multi-tenant / staging environments)
const ALLOWED = new Set([
  'https://app.example.com',
  'https://staging.example.com',
])
app.use(cors({
  origin: (origin, cb) => {
    // Allow no-origin (curl/Postman) and listed origins
    cb(null, !origin || ALLOWED.has(origin))
  },
  credentials: true,
}))

// Handle OPTIONS for ALL routes (required for preflight)
app.options('*', cors())

Python / FastAPI

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

# Add before any routes — middleware order matters
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.example.com"],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
    allow_headers=["Content-Type", "Authorization"],
    max_age=86400,
    expose_headers=["X-Request-ID"],
)

Nginx (reverse proxy)

location /api/ {
    # Handle preflight
    if ($request_method = OPTIONS) {
        add_header 'Access-Control-Allow-Origin'  '$http_origin' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;
        add_header 'Access-Control-Max-Age' '86400' always;
        return 204;
    }
    add_header 'Access-Control-Allow-Origin'      '$http_origin' always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;
    add_header 'Vary'                             'Origin' always;

    proxy_pass http://backend:3000;
}
# Note: $http_origin echoes the request origin — combine with
# an application-level allowlist, never use in unauthenticated public APIs

The Credentials Gotcha: Wildcard + Cookies = Always Rejected

This is the most common "I did everything right but it still fails" CORS error. The Fetch specification explicitly bans combining Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true. There is no workaround — you must use an exact origin.

# ✗ Browser ALWAYS rejects this — no workaround exists
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

# ✓ Required: exact origin when credentials: true
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

# On the client side, credentials must be explicitly opted into:
fetch('https://api.example.com/me', {
  credentials: 'include',  // Send cookies cross-origin
})

# axios equivalent:
axios.get('https://api.example.com/me', {
  withCredentials: true,
})

# The "credentials" here means:
# - Cookies
# - HTTP authentication headers
# - TLS client certificates

CORS in Local Development: The Proxy Solution

In local development, your frontend on localhost:5173 calling an API on localhost:3000 is a cross-origin request (different port). Rather than configuring CORS headers for development, use a dev proxy — the browser sees only one origin.

// vite.config.ts — forward /api/* to backend
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^/api/, ''),
      },
    },
  },
})
// Browser: fetch('/api/users')  → same-origin ✓
// Vite:    forwards to http://localhost:3000/users

// Next.js next.config.ts
const nextConfig = {
  async rewrites() {
    return [{ source: '/api/:path*', destination: 'http://localhost:4000/:path*' }]
  },
}

// CRA package.json — simplest option
{
  "proxy": "http://localhost:3000"
}

Never install browser extensions that disable CORS checks for development. They mask real production bugs and are frequently left enabled, creating security vulnerabilities in your actual browsing sessions.

Understanding CORS is closely tied to understanding HTTP status codes. The HTTP Status Codes guide explains what 200, 204, 404, and 405 mean for your preflight OPTIONS responses.

Common CORS Errors and Their Root Causes

# Error 1 — No 'Access-Control-Allow-Origin' header present
# Root cause: server does not set the header at all
# Fix: Add cors() middleware / CORSMiddleware to your server

# Error 2 — Preflight request did not succeed (OPTIONS → 404)
# Root cause: no route handles OPTIONS method
# Fix: app.options('*', cors())  or return 204 from OPTIONS handler

# Error 3 — Wildcard cannot be used with credentials
# Root cause: credentials: true combined with Access-Control-Allow-Origin: *
# Fix: Specify exact origin instead of *

# Error 4 — Header value differs from request Origin
# Check: your Access-Control-Allow-Origin exactly matches
#         the Origin the browser sent (no trailing slash, exact scheme)
curl -sI -H "Origin: https://app.example.com" https://api.example.com/health   | grep -i 'access-control'
# Expected: access-control-allow-origin: https://app.example.com

# Error 5 — Header appears twice in response (CDN + app both set it)
# Root cause: two middleware layers both add the header
# Browser rejects: "Multiple Access-Control-Allow-Origin: ..."
# Fix: configure CORS in ONE place only (app or CDN, not both)

# Debugging commands
# 1. Check preflight response
curl -sv -X OPTIONS https://api.example.com/users   -H "Origin: https://app.example.com"   -H "Access-Control-Request-Method: POST"   -H "Access-Control-Request-Headers: Content-Type, Authorization"   2>&1 | grep -i 'access-control|< HTTP'

# 2. Check actual request response
curl -sv -X POST https://api.example.com/users   -H "Origin: https://app.example.com"   -H "Content-Type: application/json"   -d '{}'   2>&1 | grep -i 'access-control|< HTTP'

Frequently Asked Questions

What does CORS stand for?

CORS stands for Cross-Origin Resource Sharing. It is an HTTP-header based mechanism that allows a server to tell browsers which origins other than its own are permitted to read its responses. It is defined in the Fetch specification (WHATWG) and extends the Same-Origin Policy defined in RFC 6454.

Why does CORS exist?

CORS exists because of the Same-Origin Policy, which prevents JavaScript from reading cross-origin responses by default. This protects users from malicious sites using their session cookies to fetch private data. CORS is the controlled relaxation — servers explicitly declare which cross-origin requests are safe.

What is a cross-origin request?

A request where the page and the resource have different origins. Origin = scheme + host + port. https://app.example.com and https://api.example.com are cross-origin — different subdomain. Localhost:3000 and localhost:5173 are cross-origin — different port. Any difference in the three-part origin tuple triggers cross-origin handling.

Does CORS apply to images and CSS?

Not by default. HTML loads images, stylesheets, and scripts from other origins without CORS. CORS applies when JavaScript tries to READ the response via fetch() or XMLHttpRequest. A <canvas> reading cross-origin image pixels will fail without CORS headers on the image server.

Is CORS a security feature I configure on my server?

Partially. You configure CORS headers server-side, but enforcement is by the browser. CORS protects users, not your server. curl and Postman bypass CORS entirely. You still need authentication to protect server endpoints from non-browser access.

What is the Access-Control-Allow-Origin header?

The primary CORS response header. It tells the browser which origin can read the response. Use * to allow any origin (no credentials), or specify an exact origin like https://app.example.com. You cannot combine * with Access-Control-Allow-Credentials: true — the browser rejects this unconditionally.

What triggers a CORS preflight request?

Any "non-simple" request: PUT/DELETE/PATCH methods, POST with Content-Type: application/json, or any custom header like Authorization. The browser sends an OPTIONS request first. If the server does not respond correctly to OPTIONS (200 or 204 with the right CORS headers), the real request is never sent.

How do I fix a CORS error?

Always server-side. Add Access-Control-Allow-Origin with your frontend's origin. Handle OPTIONS requests and return 200/204 with Access-Control-Allow-Methods and Access-Control-Allow-Headers. In Express: app.use(cors({ origin: "https://yourapp.com" })). In FastAPI: CORSMiddleware. Never fix CORS by disabling browser security.

Inspect API Headers and Responses

Use BytePane's JSON Formatter to pretty-print and inspect API response bodies as you debug CORS. For full HTTP status code reference (what 204 vs 404 means for your OPTIONS handler), see the HTTP Status Codes guide. Building a REST or GraphQL API from scratch? The REST API Best Practices guide covers CORS alongside rate limiting, authentication, and versioning.

Open JSON Formatter

Related Articles