Back to blog

CORS Explained: The Complete Guide for Web Developers

corsweb-securityhttpbackendapi
CORS Explained: The Complete Guide for Web Developers

You're building a frontend app on localhost:3000. You call your API on localhost:8080. The browser throws a red error:

Access to fetch at 'http://localhost:8080/api/users' from origin 'http://localhost:3000' has been blocked by CORS policy.

You've just hit the most common — and most misunderstood — web security mechanism. Every web developer encounters CORS eventually, and most developers fix it by blindly adding Access-Control-Allow-Origin: * without understanding what they're actually doing.

This guide explains CORS from scratch: why it exists, how it works under the hood, and how to configure it correctly in real applications.

What You'll Learn

✅ Why browsers block cross-origin requests (same-origin policy)
✅ What CORS is and the problem it solves
✅ How simple requests vs. preflight requests work
✅ Every CORS header and what it controls
✅ How to configure CORS in Express, FastAPI, Spring Boot, and Go
✅ Common CORS errors and how to debug them
✅ Security best practices (why * is dangerous)

Prerequisites

Before diving in, you should have:


Part 1: The Same-Origin Policy

What is an "Origin"?

An origin is defined by three parts of a URL:

PartExample
Scheme (protocol)https
Host (domain)api.example.com
Port443

Two URLs have the same origin only if all three parts match exactly.

Same origin:

  • https://example.com/page1 and https://example.com/page2 — same scheme, host, port

Different origin (cross-origin):

  • http://example.com vs https://example.com — different scheme
  • https://example.com vs https://api.example.com — different host (subdomain counts)
  • https://example.com vs https://example.com:8080 — different port

Why Does the Same-Origin Policy Exist?

The same-origin policy (SOP) is a browser security mechanism that restricts how scripts from one origin can interact with resources from another origin.

Without it, any website could:

  1. Steal your data: A malicious site could fetch your Gmail inbox via fetch('https://mail.google.com/api/messages') — your browser would attach your Gmail cookies automatically
  2. Perform actions on your behalf: Transfer money from your bank, post on your social media, change your passwords
  3. Read sensitive responses: Access tokens, personal data, private API responses

The same-origin policy ensures that your browser won't let one website read responses from another website unless explicitly allowed.

What SOP Does and Doesn't Block

Blocked by SOP (reading cross-origin responses):

  • fetch() and XMLHttpRequest to different origins
  • Reading content from cross-origin <iframe>
  • Accessing cross-origin <canvas> data

NOT blocked by SOP (embedding is fine):

  • <img src="https://other-site.com/image.png"> — images load cross-origin
  • <script src="https://cdn.example.com/lib.js"> — scripts execute cross-origin
  • <link href="https://fonts.googleapis.com/..."> — stylesheets load cross-origin
  • <form action="https://other-site.com/submit"> — forms submit cross-origin

The key insight: embedding cross-origin resources is allowed, but reading cross-origin responses programmatically is blocked.


Part 2: What is CORS?

The Problem

The same-origin policy is great for security, but it's too restrictive for modern web development. Legitimate use cases include:

  • Frontend on app.example.com calling API on api.example.com
  • A React app on localhost:3000 calling a backend on localhost:8080
  • A mobile app's web view calling your API
  • Third-party integrations consuming your public API

The Solution: CORS

CORS (Cross-Origin Resource Sharing) is a mechanism that lets servers tell browsers: "It's okay — allow this cross-origin request."

It works through HTTP headers. The server includes specific headers in its response that tell the browser which origins, methods, and headers are permitted.

CORS is not a security feature that protects your server — your server processes the request either way. CORS tells the browser whether to let the frontend JavaScript read the response.

Important: CORS is enforced by the browser, not the server. Tools like curl, Postman, and server-to-server requests completely bypass CORS because they don't implement the same-origin policy.


Part 3: How CORS Works — Simple vs. Preflight Requests

Simple Requests

A simple request doesn't trigger a preflight check. The browser sends the request directly and checks the response headers.

A request is "simple" if it meets all of these conditions:

ConditionAllowed Values
MethodGET, HEAD, POST
Content-Typetext/plain, multipart/form-data, application/x-www-form-urlencoded
HeadersOnly CORS-safelisted headers (Accept, Accept-Language, Content-Language, Content-Type)

Example — simple GET request:

// Frontend: https://app.example.com
fetch('https://api.example.com/posts')
  .then(response => response.json())
  .then(data => console.log(data));

What happens under the hood:

GET /posts HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json
 
[{"id": 1, "title": "Hello World"}]

The browser sees Access-Control-Allow-Origin matches the requesting origin → response is accessible to JavaScript.

Preflight Requests

Any request that doesn't qualify as "simple" triggers a preflight request — an extra OPTIONS request that asks the server for permission before sending the actual request.

Common triggers for preflight:

  • Using methods like PUT, DELETE, PATCH
  • Sending Content-Type: application/json
  • Including custom headers like Authorization, X-Request-ID

Example — POST with JSON (triggers preflight):

// Frontend: https://app.example.com
fetch('https://api.example.com/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...'
  },
  body: JSON.stringify({ name: 'Alice' })
});

Step 1: Browser sends OPTIONS preflight:

OPTIONS /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 responds with permissions:

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: 3600

Step 3: Browser sends actual request (if preflight passed):

POST /users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
 
{"name": "Alice"}

The full flow visualized:

Preflight Caching

Preflight requests add latency because every cross-origin API call requires two round trips. The Access-Control-Max-Age header tells the browser to cache the preflight result:

Access-Control-Max-Age: 3600  // Cache for 1 hour

After caching, subsequent requests to the same endpoint skip the preflight for the duration of the cache.

BrowserMax Access-Control-Max-Age
Chrome7200 (2 hours)
Firefox86400 (24 hours)
Safari604800 (7 days)

Part 4: CORS Headers Explained

Request Headers (Browser → Server)

These headers are automatically set by the browser — you never set them manually:

HeaderPurposeExample
OriginThe origin making the requesthttps://app.example.com
Access-Control-Request-MethodMethod the actual request will use (preflight only)PUT
Access-Control-Request-HeadersCustom headers the actual request will include (preflight only)Content-Type, Authorization

Response Headers (Server → Browser)

These are the headers your server must set:

HeaderPurposeExample
Access-Control-Allow-OriginWhich origins are allowedhttps://app.example.com or *
Access-Control-Allow-MethodsWhich HTTP methods are allowedGET, POST, PUT, DELETE
Access-Control-Allow-HeadersWhich request headers are allowedContent-Type, Authorization
Access-Control-Expose-HeadersWhich response headers JavaScript can readX-Total-Count, X-Request-ID
Access-Control-Allow-CredentialsAllow cookies/auth headerstrue
Access-Control-Max-AgePreflight cache duration (seconds)3600

The Credentials Problem

By default, cross-origin requests don't include cookies or Authorization headers. To send credentials:

Frontend must opt in:

fetch('https://api.example.com/profile', {
  credentials: 'include'  // Send cookies cross-origin
});

Server must opt in:

Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://app.example.com  // MUST be specific, NOT *

Critical rule: When Access-Control-Allow-Credentials: true is set, Access-Control-Allow-Origin cannot be *. You must specify the exact origin. This is a security feature — wildcard + credentials would let any website make authenticated requests to your API.


Part 5: Configuring CORS in Real Applications

Node.js / Express

const express = require('express');
const cors = require('cors');
const app = express();
 
// Option 1: Allow all origins (development only!)
app.use(cors());
 
// Option 2: Specific origin
app.use(cors({
  origin: 'https://app.example.com'
}));
 
// Option 3: Multiple origins with credentials
app.use(cors({
  origin: ['https://app.example.com', 'https://admin.example.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 3600
}));
 
// Option 4: Dynamic origin validation
app.use(cors({
  origin: (origin, callback) => {
    const allowedOrigins = [
      'https://app.example.com',
      'https://admin.example.com'
    ];
 
    // Allow requests with no origin (server-to-server, curl, etc.)
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true
}));
 
app.listen(8080);

Python / FastAPI

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
 
app = FastAPI()
 
# Add CORS middleware
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.example.com", "https://admin.example.com"],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Content-Type", "Authorization"],
    max_age=3600,
)
 
@app.get("/api/users")
async def get_users():
    return [{"id": 1, "name": "Alice"}]

Java / Spring Boot

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
 
@Configuration
public class CorsConfig {
 
    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOrigin("https://app.example.com");
        config.addAllowedOrigin("https://admin.example.com");
        config.addAllowedMethod("*");
        config.addAllowedHeader("*");
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);
 
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return new CorsFilter(source);
    }
}

Or use annotations per controller:

@RestController
@CrossOrigin(origins = "https://app.example.com", maxAge = 3600)
@RequestMapping("/api/users")
public class UserController {
 
    @GetMapping
    public List<User> getUsers() {
        return userService.findAll();
    }
}

Go (net/http)

package main
 
import (
	"encoding/json"
	"net/http"
	"slices"
)
 
func corsMiddleware(next http.Handler) http.Handler {
	allowedOrigins := []string{
		"https://app.example.com",
		"https://admin.example.com",
	}
 
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		origin := r.Header.Get("Origin")
 
		if slices.Contains(allowedOrigins, origin) {
			w.Header().Set("Access-Control-Allow-Origin", origin)
			w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
			w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
			w.Header().Set("Access-Control-Allow-Credentials", "true")
			w.Header().Set("Access-Control-Max-Age", "3600")
		}
 
		// Handle preflight
		if r.Method == http.MethodOptions {
			w.WriteHeader(http.StatusNoContent)
			return
		}
 
		next.ServeHTTP(w, r)
	})
}
 
func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /api/users", func(w http.ResponseWriter, r *http.Request) {
		json.NewEncoder(w).Encode(map[string]string{"name": "Alice"})
	})
 
	http.ListenAndServe(":8080", corsMiddleware(mux))
}

Nginx (Reverse Proxy)

If your backend doesn't handle CORS, configure it at the Nginx level:

server {
    listen 80;
    server_name api.example.com;
 
    location /api/ {
        # CORS headers
        add_header 'Access-Control-Allow-Origin' 'https://app.example.com' 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' '3600' always;
 
        # Handle preflight
        if ($request_method = 'OPTIONS') {
            return 204;
        }
 
        proxy_pass http://localhost:8080;
    }
}

Warning: Don't set CORS headers in both Nginx and your application — you'll end up with duplicate headers, which browsers reject.


Part 6: Common CORS Errors and How to Debug

Error 1: "No 'Access-Control-Allow-Origin' header is present"

Access to fetch at 'https://api.example.com/data' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.

Cause: Your server isn't returning the Access-Control-Allow-Origin header.

Fix: Add CORS headers to your server response. Make sure they're returned for all responses, including error responses (4xx, 5xx).

Error 2: "The 'Access-Control-Allow-Origin' header has a value that is not equal to the supplied origin"

Cause: The server returned Access-Control-Allow-Origin: https://other-site.com but your app is on https://app.example.com.

Fix: Update your server's allowed origins list to include the requesting origin.

Error 3: "Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*'"

Cause: You're sending credentials: 'include' but the server responds with Access-Control-Allow-Origin: *.

Fix: Set a specific origin instead of * when credentials are needed.

Error 4: "Method PUT is not allowed by Access-Control-Allow-Methods"

Cause: Preflight check failed because your server doesn't list PUT in Access-Control-Allow-Methods.

Fix: Add the missing method to your CORS configuration.

Error 5: "Request header field Authorization is not allowed"

Cause: The Authorization header isn't listed in Access-Control-Allow-Headers.

Fix: Add Authorization to your allowed headers configuration.

Debugging Checklist

When you hit a CORS error, follow this systematic approach:

  1. Open DevTools → Network tab: Find the failing request. Look for a red preflight (OPTIONS) request.

  2. Inspect the preflight response: Check if the server returns the correct Access-Control-* headers.

  3. Use curl to verify: Test without browser CORS enforcement:

    # Check if server returns CORS headers
    curl -i -X OPTIONS https://api.example.com/data \
      -H "Origin: https://app.example.com" \
      -H "Access-Control-Request-Method: POST" \
      -H "Access-Control-Request-Headers: Content-Type, Authorization"
  4. Check error responses: CORS headers must be present on all responses, including 401, 403, 404, and 500. A common mistake is only adding CORS headers to successful responses.

  5. Check for duplicate headers: If you have CORS configured in both your reverse proxy (Nginx) and your application, browsers may reject duplicate headers.


Part 7: Security Best Practices

1. Never Use * in Production with Credentials

// ❌ DANGEROUS: allows any website to call your API
app.use(cors({ origin: '*' }));
 
// ✅ SAFE: only your frontend can call the API
app.use(cors({ origin: 'https://app.example.com' }));

Using Access-Control-Allow-Origin: * means any website can read responses from your API. This is fine for truly public APIs (e.g., a weather API), but dangerous for any API that serves user-specific data.

2. Validate Origins Dynamically

For multi-tenant applications or multiple frontends, validate against a whitelist:

// ❌ BAD: regex that's too permissive
origin: /example\.com$/  // Matches evil-example.com too!
 
// ✅ GOOD: explicit whitelist
const allowedOrigins = new Set([
  'https://app.example.com',
  'https://admin.example.com',
  'https://staging.example.com'
]);
 
origin: (origin, callback) => {
  if (!origin || allowedOrigins.has(origin)) {
    callback(null, true);
  } else {
    callback(new Error('Not allowed by CORS'));
  }
}

3. Don't Reflect the Origin Header Blindly

// ❌ EXTREMELY DANGEROUS: reflects any origin
app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
  res.setHeader('Access-Control-Allow-Credentials', 'true');
  next();
});

This is functionally equivalent to * with credentials — any site can make authenticated requests to your API.

4. Limit Exposed Methods and Headers

Only expose what your API actually needs:

// ❌ Overly permissive
methods: '*',
allowedHeaders: '*',
 
// ✅ Only what you need
methods: ['GET', 'POST'],
allowedHeaders: ['Content-Type', 'Authorization'],

5. Consider Alternatives to CORS

Sometimes you don't need CORS at all:

ScenarioSolution
Frontend and API on same domainNo CORS needed — use path-based routing (/api/*)
Next.js app with API routesAPI routes run on the same origin
Reverse proxy (Nginx)Proxy /api requests to backend — browser sees same origin
BFF (Backend for Frontend)Server-side API calls don't have CORS restrictions

Part 8: CORS in Different Scenarios

Single-Page Applications (SPA)

Most SPAs have a separate frontend and backend. CORS is typically needed during development and production:

// Development: React on :3000, API on :8080
app.use(cors({
  origin: process.env.NODE_ENV === 'production'
    ? 'https://app.example.com'
    : 'http://localhost:3000',
  credentials: true
}));

Microservices

In a microservices architecture, CORS should be configured at the API gateway level, not in each individual service:

Public APIs

For truly public APIs with no authentication:

app.use(cors({
  origin: '*',           // Anyone can use the API
  methods: ['GET'],      // Read-only
  // No credentials support
}));

WebSocket and Server-Sent Events

CORS doesn't apply to WebSocket connections (ws:// or wss://). The WebSocket handshake uses an Origin header, but the server must validate it manually — browsers don't enforce CORS for WebSockets.

Server-Sent Events (SSE) use regular HTTP, so CORS applies normally.


Summary and Key Takeaways

Same-origin policy blocks cross-origin reads to prevent data theft. CORS selectively relaxes this restriction through HTTP headers.

The essentials:

  • CORS is a browser mechanism — curl and server-to-server calls ignore it
  • Simple requests (GET/POST with basic headers) go directly; preflight requests (OPTIONS) check permission first
  • Access-Control-Allow-Origin is the most important header — set it to specific origins, not *
  • When sending cookies or auth headers, you need credentials: 'include' on the client and Access-Control-Allow-Credentials: true on the server (with a specific origin)
  • Cache preflight responses with Access-Control-Max-Age to reduce latency
  • Consider same-origin architectures (reverse proxy, BFF pattern) to avoid CORS entirely

Common mistakes to avoid:

  • Using * with credentials
  • Reflecting the Origin header without validation
  • Forgetting CORS headers on error responses
  • Configuring CORS in both Nginx and the application (duplicate headers)
  • Using overly permissive regex for origin validation

If you found this helpful, check out these related posts:

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.