← Back to Blog

Complete CORS Configuration Guide: Cross-Origin Resource Sharing Explained

What is the Same-Origin Policy?

The Same-Origin Policy (SOP) is one of the browser's most fundamental security mechanisms. It dictates that scripts can only access resources that share the same origin as the current page. An "origin" requires three components to match simultaneously:

  • Scheme: https and http are different origins
  • Host: api.example.com and example.com are different origins
  • Port: :8080 and :3000 are different origins

For example, when a page at https://example.com/page accesses these resources:

Target URL Same Origin? Reason
https://example.com/api Same scheme, host, port
http://example.com/api Different scheme
https://api.example.com/data Different host (subdomains count)
https://example.com:8080/api Different port

While SOP improves security, it also blocks legitimate cross-origin requests — modern web apps with separated frontends/backends and third-party API calls are the norm. CORS is the standard designed to resolve this tension.

What is CORS?

CORS (Cross-Origin Resource Sharing, RFC 6454 / Fetch standard) is a set of HTTP header mechanisms that lets a server declare which external origins may access its resources through a browser. It is enforced by the browser on the client side, not by the server.

This is crucial to understand: CORS protects the browser user, not the server. The server always processes the request; the browser just decides based on response headers whether to hand the result to JavaScript.

Simple Requests vs. Preflight Requests

CORS classifies cross-origin requests into two categories with different flows.

Simple Requests

A request is sent "directly" without a preflight if it meets ALL of these:

  • Method is GET, HEAD, or POST
  • Only uses safe headers: Accept, Accept-Language, Content-Language, Content-Type
  • Content-Type is limited to: text/plain, multipart/form-data, application/x-www-form-urlencoded
  • No ReadableStream object is used
  • No event listeners registered on XMLHttpRequestUpload

The browser sends the request directly; the server must include Access-Control-Allow-Origin in the response or the browser refuses to deliver it to the script.

Preflight Requests

Requests that don't qualify as simple (typical cases: POST with application/json, PUT/DELETE methods, custom headers like Authorization) trigger a preliminary OPTIONS request first.

Preflight flow:

Browser                Server
  │                       │
  │── OPTIONS /api ──────▶│   (preflight: asking permission)
  │                       │
  │◀─ 200 + CORS headers ─│
  │                       │
  │── POST /api ─────────▶│   (actual request)
  │                       │
  │◀─ 201 + CORS headers ─│

The preflight carries these headers:

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

Core Response Headers Explained

Access-Control-Allow-Origin

The most critical header, specifying the allowed origin:

# Allow a single origin
Access-Control-Allow-Origin: https://app.example.com

# Allow all origins (use with caution, incompatible with credentials)
Access-Control-Allow-Origin: *

Note: If the request carries cookies (credentials: 'include'), you cannot use * — you must specify the exact origin.

Access-Control-Allow-Methods

Declares allowed HTTP methods in the preflight response:

Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS

Access-Control-Allow-Headers

Declares allowed request headers in the preflight response:

Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With

Access-Control-Expose-Headers

By default, JavaScript can only read a handful of "safe" response headers (Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma). To expose others, declare them:

Access-Control-Expose-Headers: X-Total-Count, X-Request-Id

Access-Control-Allow-Credentials

Allows requests to carry cookies, HTTP auth, or client SSL certificates:

Access-Control-Allow-Credentials: true

The frontend must also opt in:

fetch('https://api.example.com/data', {
  credentials: 'include'  // send credentials
});

Access-Control-Max-Age

How long (in seconds) to cache the preflight result, avoiding repeated OPTIONS:

Access-Control-Max-Age: 86400  // cache for a day

Browsers cap this (Chrome ~2 hours, Firefox ~24 hours); excess is truncated.

Common Configuration Patterns

Pattern 1: Nginx Reverse Proxy (Recommended)

Making frontend and backend same-origin is the simplest solution. Nginx forwards API requests to the backend:

server {
    listen 80;
    server_name example.com;

    location / {
        root /var/www/frontend;
        try_files $uri $uri/ /index.html;
    }

    location /api/ {
        proxy_pass http://backend:3000/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

The frontend accesses /api/... same-origin — no CORS config needed at all.

Pattern 2: Dynamic Whitelist (Node.js Express)

const express = require('express');
const cors = require('cors');

const app = express();

const whitelist = [
  'https://app.example.com',
  'https://admin.example.com'
];

const corsOptions = {
  origin: (origin, callback) => {
    // Allow whitelisted, or non-browser requests (origin undefined, e.g. curl)
    if (!origin || whitelist.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400
};

app.use(cors(corsOptions));

Pattern 3: Spring Boot Global Config

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("https://app.example.com")
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);
    }
}

Pattern 4: Wildcard Subdomains

To allow all subdomains:

# Can't use a literal wildcard — must match via regex
Access-Control-Allow-Origin: https://*.example.com

In practice, the backend reads the Origin header and validates with a regex:

const origin = req.headers.origin;
if (/^https:\/\/[a-z]+\.example\.com$/.test(origin)) {
  res.setHeader('Access-Control-Allow-Origin', origin);
}

Debugging CORS Issues

Step 1: Read the Browser Console

CORS error messages are quite explicit. Common ones:

  • No 'Access-Control-Allow-Origin' header is present: server didn't return the header
  • The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when credentials mode is 'include': used * with credentials
  • Method PUT not allowed: Access-Control-Allow-Methods doesn't include PUT

Step 2: Inspect the Preflight Request

Open DevTools → Network, filter by OPTIONS, and check:

  1. The request carries Access-Control-Request-Method and Access-Control-Request-Headers
  2. The response returns 200/204 with complete CORS headers
  3. Access-Control-Allow-Origin matches the request's Origin

Step 3: Common Pitfalls

  • Response is 200 but browser still errors: CORS checks happen after the response arrives. Even with a 200 status, the browser will block the response if headers are missing. Backend logs show success because the request "succeeded."
  • Nginx add_header not working: add_header only applies to 2xx/3xx by default, not 4xx/5xx. Use add_header ... always.
  • Preflight blocked by auth middleware: OPTIONS requests should not require authentication. Exempt OPTIONS before the auth middleware runs.
  • CDN cached wrong headers: A CDN may cache CORS headers from the first request, causing subsequent requests from different origins to get the wrong Access-Control-Allow-Origin. Either cache per Origin bucket on the CDN, or add Vary: Origin.

Step 4: Simulate Preflight with curl

curl -X OPTIONS https://api.example.com/api/users \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization" \
  -i

Check whether the response headers are complete.

Security Considerations

  1. Don't blindly use Access-Control-Allow-Origin: *, especially with credentials.
  2. Always use a whitelist in production, explicitly listing allowed origins.
  3. Access-Control-Allow-Credentials: true and * are mutually exclusive — the browser will reject it.
  4. CORS is not a replacement for auth. It only controls whether the browser can read responses; an attacker can still make requests from the server side. Sensitive operations need CSRF tokens and SameSite cookies.
  5. Note that CORS does not block <img>, <script>, <link> tags from loading cross-origin resources — these aren't subject to SOP (but their content can't be read).

Summary

Key Point Description
Same-Origin Policy Browser security cornerstone, restricts cross-origin script access
CORS HTTP header mechanism for servers to declare allowed cross-origin access
Simple Request Sent directly; response needs Allow-Origin
Preflight Request OPTIONS goes first; triggered by complex requests
Credentials Mode Can't use * when credentials: true
Recommended Order Nginx same-origin proxy > backend whitelist > wildcards

The key to mastering CORS: it's enforced by the browser and negotiated via response headers. Always follow the principle of least privilege — only allow the origins, methods, and headers you genuinely need.

← Back to Blog