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:
- Basic understanding of HTTP methods and headers
- Familiarity with REST APIs
- Basic frontend/backend development experience
Part 1: The Same-Origin Policy
What is an "Origin"?
An origin is defined by three parts of a URL:
| Part | Example |
|---|---|
| Scheme (protocol) | https |
| Host (domain) | api.example.com |
| Port | 443 |
Two URLs have the same origin only if all three parts match exactly.
Same origin:
https://example.com/page1andhttps://example.com/page2— same scheme, host, port
Different origin (cross-origin):
http://example.comvshttps://example.com— different schemehttps://example.comvshttps://api.example.com— different host (subdomain counts)https://example.comvshttps://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:
- 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 - Perform actions on your behalf: Transfer money from your bank, post on your social media, change your passwords
- 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()andXMLHttpRequestto 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.comcalling API onapi.example.com - A React app on
localhost:3000calling a backend onlocalhost: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:
| Condition | Allowed Values |
|---|---|
| Method | GET, HEAD, POST |
| Content-Type | text/plain, multipart/form-data, application/x-www-form-urlencoded |
| Headers | Only 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.comHTTP/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, AuthorizationStep 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: 3600Step 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 hourAfter caching, subsequent requests to the same endpoint skip the preflight for the duration of the cache.
| Browser | Max Access-Control-Max-Age |
|---|---|
| Chrome | 7200 (2 hours) |
| Firefox | 86400 (24 hours) |
| Safari | 604800 (7 days) |
Part 4: CORS Headers Explained
Request Headers (Browser → Server)
These headers are automatically set by the browser — you never set them manually:
| Header | Purpose | Example |
|---|---|---|
Origin | The origin making the request | https://app.example.com |
Access-Control-Request-Method | Method the actual request will use (preflight only) | PUT |
Access-Control-Request-Headers | Custom headers the actual request will include (preflight only) | Content-Type, Authorization |
Response Headers (Server → Browser)
These are the headers your server must set:
| Header | Purpose | Example |
|---|---|---|
Access-Control-Allow-Origin | Which origins are allowed | https://app.example.com or * |
Access-Control-Allow-Methods | Which HTTP methods are allowed | GET, POST, PUT, DELETE |
Access-Control-Allow-Headers | Which request headers are allowed | Content-Type, Authorization |
Access-Control-Expose-Headers | Which response headers JavaScript can read | X-Total-Count, X-Request-ID |
Access-Control-Allow-Credentials | Allow cookies/auth headers | true |
Access-Control-Max-Age | Preflight 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: trueis set,Access-Control-Allow-Origincannot 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:
-
Open DevTools → Network tab: Find the failing request. Look for a red preflight (
OPTIONS) request. -
Inspect the preflight response: Check if the server returns the correct
Access-Control-*headers. -
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" -
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.
-
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:
| Scenario | Solution |
|---|---|
| Frontend and API on same domain | No CORS needed — use path-based routing (/api/*) |
| Next.js app with API routes | API 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 —
curland server-to-server calls ignore it - Simple requests (GET/POST with basic headers) go directly; preflight requests (
OPTIONS) check permission first Access-Control-Allow-Originis the most important header — set it to specific origins, not*- When sending cookies or auth headers, you need
credentials: 'include'on the client andAccess-Control-Allow-Credentials: trueon the server (with a specific origin) - Cache preflight responses with
Access-Control-Max-Ageto reduce latency - Consider same-origin architectures (reverse proxy, BFF pattern) to avoid CORS entirely
Common mistakes to avoid:
- Using
*with credentials - Reflecting the
Originheader 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
Related Posts
If you found this helpful, check out these related posts:
- HTTP Protocol Complete Guide — Deep dive into the protocol that CORS builds upon
- What is REST API? Complete Guide — API design principles and best practices
- Web Security Fundamentals — CIA triad, threat modeling, and defense in depth
- OWASP Top 10 — The most critical web application security risks
- Express Middleware Deep Dive — How middleware like CORS works in Express
- Authentication in FastAPI — JWT and OAuth2 patterns that work with CORS
📬 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.