Cross-Origin Resource Sharing (CORS)
Master the isolation stack: CORS, CORB, CORP, and COEP for controlling cross-origin access
Introduction: The Same-Origin Policy Problem and CORS Solution
Have you ever clicked a button on a website only to see a cryptic error message in your browser's console: "Access to fetch at 'https://api.example.com' from origin 'https://myapp.com' has been blocked by CORS policy"? If you're a web developer, you've almost certainly encountered this frustrating message at least onceβand probably many times. This isn't a bug in your code or a misconfigured server setting you forgot about. Instead, you've just bumped into one of the web's most fundamental security mechanisms: the Same-Origin Policy, and its carefully designed exception mechanism, Cross-Origin Resource Sharing (CORS). Understanding these concepts is essential for any modern web developer, and this lesson comes with free flashcards to help you master the key terms and principles that govern how browsers allow or block requests across different websites.
But why would browsers intentionally block requests between websites? Isn't the whole point of the web to connect different resources together? To understand CORS, we first need to step back and appreciate the security problem it solvesβand why that problem is so critical that browsers decided to block perfectly valid HTTP requests by default.
The Foundation: Why the Same-Origin Policy Exists
Imagine you're logged into your bank account at https://mybank.com in one browser tab. Your authentication cookies are stored in the browser, automatically sent with every request to mybank.com. Now, in another tab, you visit a malicious website at https://evil.com. Without any browser protections, what would stop evil.com from making JavaScript requests to https://mybank.com/api/transfer?to=attacker&amount=10000? Since your browser would automatically include your authentication cookies with that request, the bank's server would see it as a legitimate request from youβthe authenticated user.
π― Key Principle: The Same-Origin Policy (SOP) is the foundational security model that prevents exactly this scenario. Implemented in all modern browsers since the late 1990s, SOP restricts how documents or scripts loaded from one origin can interact with resources from another origin.
An origin is defined by three components:
- Protocol (scheme):
httpvshttps - Domain (host):
example.comvsapi.example.com - Port:
:80vs:443vs:3000
Two URLs have the same origin only if all three components match exactly. This means:
https://example.comandhttps://example.com/pageβ Same origin βhttps://example.comandhttp://example.comβ Different origins (protocol differs) βhttps://example.comandhttps://api.example.comβ Different origins (subdomain differs) βhttps://example.com:443andhttps://example.com:3000β Different origins (port differs) β
π‘ Mental Model: Think of origins as separate security compartments. By default, JavaScript running in one compartment cannot reach into another compartment to read data or make requests on behalf of the user. This isolation protects users from having their authenticated sessions exploited by malicious third-party sites.
The Same-Origin Policy specifically restricts:
- Cross-origin HTTP requests initiated from scripts (using
XMLHttpRequestorfetch) - Access to the response data from cross-origin requests
- Reading content from cross-origin iframes, windows, and documents
- Accessing cookies, localStorage, and other storage from different origins
π€ Did you know? The Same-Origin Policy doesn't block all cross-origin interactions. You can still embed cross-origin images (<img>), scripts (<script>), stylesheets (<link>), videos (<video>), and iframes (<iframe>) in your pages. However, while these resources can be loaded and executed, JavaScript cannot read their contents if they come from a different origin. For example, you can embed an image from another domain, but you cannot use JavaScript to read its pixel data without permission.
Here's a simple example that demonstrates the Same-Origin Policy in action:
// Running on https://myapp.com
// Trying to fetch data from a different origin
fetch('https://api.different-site.com/user-data')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => {
// This will likely trigger a CORS error:
// "Access blocked by CORS policy: No 'Access-Control-Allow-Origin' header"
console.error('Request failed:', error);
});
// Even though this is a valid HTTP request and the server responds,
// the browser blocks JavaScript from accessing the response!
In this code, even if api.different-site.com successfully processes the request and sends back data, the browser will refuse to let your JavaScript code access that response. The request might even complete successfully at the network level, but the browser acts as a gatekeeper, blocking your script from seeing the result.
The Problem: When Security Becomes a Limitation
While the Same-Origin Policy provides crucial security, it creates significant challenges for legitimate web development patterns that have become standard in modern applications:
1. API-Driven Architectures
Modern web applications typically separate their frontend and backend concerns. A React or Vue.js application hosted at https://app.example.com needs to communicate with a REST or GraphQL API hosted at https://api.example.com. Despite being part of the same service, these are different origins (different subdomains), and SOP blocks the requests.
2. Content Delivery Networks (CDNs)
Applications serve static assets (fonts, images, scripts) from CDNs like https://cdn.cloudflare.com or https://d1234.cloudfront.net to improve performance through geographic distribution and caching. Accessing certain types of resources (like fonts or fetching JSON data) from these different origins requires explicit permission.
3. Microservices and Service-Oriented Architecture
Large applications often split functionality across multiple services, each with its own domain: https://auth.example.com, https://payments.example.com, https://analytics.example.com. A frontend needs to coordinate between these services, but each is a separate origin.
4. Third-Party APIs
Applications regularly integrate with external services: weather APIs, payment processors, social media platforms, mapping services, and more. A weather widget on your site needs to fetch data from https://api.weatherservice.com, which is clearly a different origin.
5. Multi-Tenant Applications
SaaS applications might give each customer their own subdomain (https://customer1.saas.com, https://customer2.saas.com) but share common resources from a central API. These subdomains are different origins from each other.
π‘ Real-World Example: Consider a modern single-page application (SPA) for an e-commerce site:
- The main application runs at
https://shop.example.com - The product API is at
https://api.example.com/products - User authentication is handled by
https://auth.example.com - Payment processing goes through
https://payments.example.com - Product images are served from
https://cdn.example.com - Customer reviews come from
https://reviews.example.com
Every single one of these interactions is a cross-origin request that would be blocked by default without some mechanism to safely allow them.
Enter CORS: A Controlled Exception Mechanism
Cross-Origin Resource Sharing (CORS) emerged as the solution to this tension between security and functionality. Standardized by the W3C (World Wide Web Consortium) and implemented across all modern browsers, CORS provides a controlled mechanism for servers to explicitly declare which cross-origin requests they want to allow.
π― Key Principle: CORS shifts the permission model from "block everything cross-origin" to "block everything cross-origin except what the server explicitly allows." The server, not the client, decides which cross-origin requests to permit.
Here's the fundamental insight behind CORS: the server hosting the resource is in the best position to decide who should be allowed to access it. If api.example.com wants to allow requests from app.example.com, it can declare that permission. If it wants to allow requests from any origin, it can declare that too. But critically, malicious sites cannot give themselves permission to access resources on other domainsβonly the resource server can grant that permission.
CORS works through a set of HTTP headers that create a protocol between the browser and the server:
The server sends headers that say:
- "I allow requests from these origins"
- "I allow these HTTP methods (GET, POST, DELETE, etc.)"
- "I allow these custom headers"
- "You can include credentials (cookies) with these requests"
- "You can cache this permission decision for X seconds"
The browser reads these headers and decides:
- "This response includes permission for my originβI'll let JavaScript access it"
- "This response doesn't include proper CORS headersβI'll block JavaScript from accessing it"
- "This request needs special headersβI'll send a preflight request first to check permission"
Here's a simple example of how a server might grant CORS permission:
// Node.js with Express - Server at https://api.example.com
const express = require('express');
const app = express();
app.get('/user-data', (req, res) => {
// The magic CORS header that grants permission:
res.header('Access-Control-Allow-Origin', 'https://app.example.com');
// Now https://app.example.com can access this response!
res.json({
id: 123,
name: 'Jane Developer',
email: 'jane@example.com'
});
});
app.listen(3000);
With just that one headerβAccess-Control-Allow-Originβthe server at api.example.com has explicitly granted permission for JavaScript running on app.example.com to access the response data. Without this header, the browser would block access even though the HTTP request and response completed successfully.
Let's see the corresponding client code that would work with this CORS-enabled server:
// Running on https://app.example.com
// This now works because the server grants permission!
fetch('https://api.example.com/user-data')
.then(response => {
if (!response.ok) throw new Error('HTTP error');
return response.json();
})
.then(data => {
// Success! The browser allowed us to access the response
// because the server included the Access-Control-Allow-Origin header
console.log('User data:', data);
// Output: User data: {id: 123, name: "Jane Developer", email: "jane@example.com"}
})
.catch(error => {
console.error('Request failed:', error);
});
π‘ Pro Tip: CORS is enforced by the browser, not the server. The server always processes the request and generates a response (unless it explicitly rejects it for other reasons). CORS headers simply tell the browser whether JavaScript should be allowed to see that response. This is why you might see a successful HTTP 200 response in your network tab but still get a CORS errorβthe response arrived, but the browser blocked JavaScript from accessing it.
CORS in the Broader Browser Security Model
CORS doesn't exist in isolationβit's part of a comprehensive security architecture that browsers implement to protect users:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Browser Security Model β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Same-Origin Policy (SOP) β β
β β Default: Isolate all origins from each other β β
β ββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ β
β β β
β βββββββββββββ΄ββββββββββββ β
β β β β
β ββββββΌββββββ βββββββΌβββββ β
β β CORS β β Other β β
β β Explicit β β Security β β
β β Relaxing β β Features β β
β ββββββ¬ββββββ βββββββ¬βββββ β
β β β β
β β ββ Content Security Policy β
β β ββ Subresource Integrity β
β β ββ Mixed Content Blocking β
β β ββ X-Frame-Options β
β β ββ Referrer Policy β
β β β
β ββ Controlled cross-origin data access β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
CORS specifically addresses cross-origin HTTP requests initiated by scripts, but it works alongside other security mechanisms:
- Content Security Policy (CSP) controls which resources can be loaded and executed
- Subresource Integrity (SRI) ensures external resources haven't been tampered with
- X-Frame-Options and frame-ancestors prevent clickjacking attacks
- Mixed Content Blocking prevents HTTPS pages from loading HTTP resources
- Cookie policies (SameSite attribute) control when cookies are sent with cross-origin requests
β οΈ Common Mistake: Developers sometimes think CORS is a "security feature" you need to configure on your server.
β Wrong thinking: "I need to add CORS to make my API secure."
β Correct thinking: "CORS allows me to selectively relax the browser's default security restriction (SOP) for specific cross-origin requests. I configure CORS to enable legitimate access while maintaining security."
CORS isn't about adding securityβit's about carefully removing a restriction in a controlled way.
The Request-Response Handshake: A Preview
At its core, CORS is a negotiation protocol between the browser and server. Here's a simplified view of how this handshake works:
Browser (https://app.example.com) Server (https://api.example.com)
β β
β 1. JavaScript initiates fetch() β
βββββββββββββββββββββββββββββββββββββββββ> β
β GET /data β
β Origin: https://app.example.com β
β β
β 2. Server processes request β
β β
β 3. Server adds CORS headers β
β <βββββββββββββββββββββββββββββββββββββββββ
β 200 OK β
β Access-Control-Allow-Origin: ... β
β (response data) β
β β
β 4. Browser checks CORS headers β
β - Match found? Allow access β
β
β - No match? Block access β β
β β
β 5. JavaScript receives response β
β (or CORS error) β
βΌ βΌ
For certain types of requests (called preflighted requests), the browser adds an extra step:
Browser Server
β β
β 1. Browser sends preflight OPTIONS β
βββββββββββββββββββββββββββββββββββββββββ> β
β OPTIONS /data β
β Origin: https://app.example.com β
β Access-Control-Request-Method: POST β
β β
β 2. Server responds with permissions β
β <βββββββββββββββββββββββββββββββββββββββββ
β 200 OK β
β Access-Control-Allow-Origin: ... β
β Access-Control-Allow-Methods: POST β
β β
β 3. Browser checks permissions β
β Approved? Continue β
β
β β
β 4. Browser sends actual request β
βββββββββββββββββββββββββββββββββββββββββ> β
β POST /data β
β (request body) β
β β
β 5. Server responds β
β <βββββββββββββββββββββββββββββββββββββββββ
β 201 Created β
β (response data) β
βΌ βΌ
π‘ Mental Model: Think of CORS like a security checkpoint at a building. The browser is a security guard who by default doesn't let anyone pass between buildings (origins). CORS is the system of visitor badges and access lists. The server hosting the resource is the building manager who decides who gets a badge. When you try to cross between buildings, the guard (browser) checks with the building manager (server) through the badge system (CORS headers) to see if you're authorized.
The preflight request (using the OPTIONS HTTP method) is like asking the security guard "Before I walk over there with all my stuff, will they even let me in?" The guard checks first, and only if permission is granted does the actual request proceed.
Why Understanding CORS Matters
As a web developer, you'll interact with CORS regularly, whether you realize it or not:
π§ As a frontend developer, you need to:
- Understand why your API requests might fail with CORS errors
- Know how to structure requests to work with CORS restrictions
- Debug CORS issues using browser developer tools
- Communicate effectively with backend developers about CORS requirements
π§ As a backend developer, you need to:
- Configure CORS headers correctly on your API endpoints
- Understand the security implications of different CORS configurations
- Handle preflight requests appropriately
- Balance security with functionality when deciding which origins to allow
π§ As a DevOps engineer, you need to:
- Configure CORS at the infrastructure level (reverse proxies, API gateways)
- Understand how CORS interacts with CDNs and caching
- Debug CORS issues in production environments
π§ As a security professional, you need to:
- Audit CORS configurations for overly permissive settings
- Understand how CORS protects against certain attack vectors
- Recognize when CORS is misconfigured in ways that create vulnerabilities
What You'll Learn in This Lesson
Throughout this comprehensive lesson on CORS, you'll develop a complete understanding of:
π Origins and Cross-Origin Requests: You'll master the precise definition of what makes requests cross-origin and develop the ability to instantly recognize when CORS will come into play.
π The CORS Protocol: You'll walk through the complete request-response flow, understanding simple requests versus preflighted requests, and learning exactly what happens at each step of the browser-server negotiation.
π Practical Implementation: You'll see concrete examples of configuring CORS on both client and server sides, across multiple frameworks and programming languages, giving you the knowledge to implement CORS correctly in your own projects.
π Debugging and Troubleshooting: You'll learn to read CORS error messages, use browser developer tools effectively, and systematically diagnose and fix common CORS problems.
π Security Best Practices: You'll understand the security implications of different CORS configurations, learn to avoid dangerous patterns like overly permissive wildcard settings, and implement CORS in a way that maintains security while enabling legitimate cross-origin access.
The Road Ahead
CORS might seem complex at firstβit's a protocol built on top of HTTP, enforced by browsers, configured by servers, and involving multiple types of requests and headers. But once you understand the fundamental principleβthat it's a controlled mechanism for servers to grant explicit exceptions to the Same-Origin Policyβeverything else follows logically.
In the sections ahead, we'll build your understanding systematically, starting with precise definitions of origins and cross-origin requests, then walking through the complete CORS protocol in detail, showing practical implementations, revealing common pitfalls, and finishing with security-conscious best practices. By the end of this lesson, CORS will transform from a frustrating source of errors into a well-understood tool that you can confidently configure and debug.
π§ Mnemonic: Remember CORS with "Client Origin Requests Server" - The client from one origin requests permission from the server at another origin to access resources.
Let's begin by establishing a precise understanding of what makes requests cross-origin in the first place.
π Quick Reference Card: Key Concepts
| π Concept | π Definition | π― Purpose |
|---|---|---|
| π Same-Origin Policy (SOP) | Default browser security model that isolates origins | Prevents malicious sites from exploiting user sessions on other sites |
| π Origin | Combination of protocol + domain + port | Defines the security boundary between web resources |
| π CORS | W3C standard allowing servers to permit cross-origin access | Enables legitimate cross-origin requests while maintaining security |
| π¨ Preflight Request | OPTIONS request sent before actual request | Browser checks server permission before sending complex cross-origin requests |
| π CORS Headers | HTTP headers like Access-Control-Allow-Origin | Communication protocol between browser and server for permissions |
With this foundation in place, you're ready to dive deeper into the mechanics of how origins are defined and when requests cross origin boundariesβour next topic in this comprehensive exploration of CORS.
Understanding Origins and Cross-Origin Requests
Before we can understand how CORS works, we need to establish a precise definition of what makes two resources "same-origin" or "cross-origin." This distinction is fundamental to web security, and browsers enforce it rigorously. Let's start by building our understanding from the ground up.
What Is an Origin?
An origin is defined by three components working together as an inseparable unit: the scheme (protocol), the host (domain), and the port. These three pieces form what's often called the origin triple. For two URLs to be considered same-origin, all three components must match exactly.
π― Key Principle: Origin = Scheme + Host + Port. Change any one component, and you've created a different origin.
Let's examine this with concrete examples. Consider the URL https://api.example.com:443/users/profile:
- Scheme:
https(the protocol) - Host:
api.example.com(the domain) - Port:
443(explicitly stated, but this is HTTPS's default port)
Now let's compare several URLs to see which share the same origin:
Base URL: https://api.example.com:443/users/profile
βββββββββββββββββββββββββββββββββββββββββββββββββββ¬βββββββββββββββ¬ββββββββββββββββββββββββββββββ
β Compared URL β Same Origin? β Reason β
βββββββββββββββββββββββββββββββββββββββββββββββββββΌβββββββββββββββΌββββββββββββββββββββββββββββββ€
β https://api.example.com/products β β
YES β All three match β
β https://api.example.com:443/admin β β
YES β 443 is default for HTTPS β
β http://api.example.com/users β β NO β Different scheme (http) β
β https://www.example.com/users β β NO β Different host (www.) β
β https://api.example.com:8443/users β β NO β Different port (8443) β
β https://example.com/users β β NO β Different host (subdomain) β
βββββββββββββββββββββββββββββββββββββββββββββββββββ΄βββββββββββββββ΄ββββββββββββββββββββββββββββββ
β οΈ Common Mistake: Assuming that example.com and www.example.com are the same origin because they're "the same website." They're notβthe host component differs, making them completely separate origins from the browser's perspective. β οΈ
π‘ Pro Tip: When the port isn't explicitly specified in a URL, browsers use the default port for that scheme: 80 for HTTP and 443 for HTTPS. So https://example.com and https://example.com:443 are treated as the same origin.
Why Origin Boundaries Matter
The browser uses origin boundaries to create security compartments. When you visit https://bank.com, JavaScript running on that page can freely access resources from the same origin. It can read cookies, make requests, and access the DOM without restriction. But the moment that JavaScript tries to interact with https://evil.com, the browser's Same-Origin Policy steps in to prevent it.
This protection exists because without it, a malicious script on one site could read your private data from another site. Imagine visiting https://evil.com, which contains JavaScript that tries to fetch https://bank.com/account-balance. If browsers didn't enforce origin boundaries, the evil site could steal your banking information.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Browser Tab: https://evil.com β
β β
β JavaScript tries to fetch: https://bank.com/account-balance β
β β
β Browser checks origins: β
β evil.com β bank.com β
β β
β π‘οΈ Same-Origin Policy BLOCKS the request β
β (unless bank.com explicitly allows it via CORS) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
How Browsers Determine Cross-Origin Requests
When your JavaScript code initiates an HTTP request using fetch() or XMLHttpRequest, the browser performs an origin check. It compares the origin of the page making the request with the origin of the resource being requested.
Let's say you're on a page at https://myapp.com/dashboard.html, and your JavaScript executes:
// Current page origin: https://myapp.com
fetch('https://api.myapp.com/data')
.then(response => response.json())
.then(data => console.log(data));
The browser compares:
- Source origin:
https://myapp.com(scheme: https, host: myapp.com, port: 443) - Target origin:
https://api.myapp.com(scheme: https, host: api.myapp.com, port: 443)
The hosts differ (myapp.com vs api.myapp.com), so this is a cross-origin request. The browser will enforce CORS rules, checking for permission from the target server before allowing your JavaScript to read the response.
π€ Did you know? The request actually gets sent to the server even when CORS blocks it. The browser blocks your JavaScript from reading the response, but the server still receives and processes the request. This is why CORS is not a replacement for server-side security.
Types of Cross-Origin Requests
Not all cross-origin requests are treated equally by browsers. CORS distinguishes between two categories: simple requests and preflighted requests. Understanding this distinction is crucial because it affects how your requests behave and what server configuration you need.
Simple Requests
Simple requests are cross-origin requests that meet specific criteria designed to ensure they're no more dangerous than what traditional HTML forms could do before CORS existed. These requests are sent directly to the server without any preliminary checks.
For a request to qualify as simple, it must satisfy all of these conditions:
π― Simple Request Criteria:
Method must be one of:
GETHEADPOST
Headers must only include:
- Browser-set headers (like
User-Agent,Accept) AcceptAccept-LanguageContent-LanguageContent-Type(with restrictionsβsee below)
- Browser-set headers (like
Content-Type (if present) must be one of:
application/x-www-form-urlencodedmultipart/form-datatext/plain
No event listeners on
XMLHttpRequest.uploadNo
ReadableStreamobject in the request
Here's an example of a simple cross-origin request:
// This is a SIMPLE request
fetch('https://api.external.com/public-data', {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
// This is also a SIMPLE request
fetch('https://api.external.com/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'name=John&age=30'
});
π‘ Mental Model: Think of simple requests as "form-level" requestsβanything a traditional HTML form could have done before modern JavaScript APIs existed. Browsers treat these as lower risk because they were already possible.
Preflighted Requests
Any cross-origin request that doesn't meet the simple request criteria becomes a preflighted request. Before sending the actual request, the browser automatically sends a preflight request using the OPTIONS HTTP method. This preflight asks the server, "Is it okay for me to send this type of request?"
The preflight mechanism exists to protect legacy servers that were built before CORS existed. These servers might not expect requests with custom headers or methods like DELETE or PUT, so the browser checks first.
PREFLIGHT FLOW:
βββββββββββ βββββββββββ
β Browser β β Server β
ββββββ¬βββββ ββββββ¬βββββ
β β
β 1. OPTIONS /api/users (preflight) β
β Origin: https://myapp.com β
β Access-Control-Request-Method: DELETE β
β Access-Control-Request-Headers: Authorization β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ>β
β β
β 2. Preflight Response β
β Access-Control-Allow-Origin: https://myapp.com β
β Access-Control-Allow-Methods: DELETE, GET β
β Access-Control-Allow-Headers: Authorization β
β<ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β 3. Actual DELETE request (if preflight approved) β
β DELETE /api/users/123 β
β Authorization: Bearer token123 β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ>β
β β
β 4. Actual Response β
β 200 OK β
β<ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
What Triggers a Preflight Request?
A preflight is triggered when your request includes any of these characteristics:
π§ HTTP Methods that require preflight:
PUTDELETEPATCHCONNECTOPTIONSTRACE
π§ Custom Headers that require preflight:
Any header not in the simple request whitelist triggers preflight. Common examples include:
Authorization(used for bearer tokens)Content-Type: application/json(JSON is not in the simple list!)X-Custom-Header(any custom header)X-Requested-With
π§ Content-Type values that require preflight:
application/jsonβ οΈ Very common trigger!application/xml- Any other type not in the simple list
β οΈ Common Mistake: Developers often don't realize that sending JSON data (Content-Type: application/json) triggers a preflight. This is one of the most frequent sources of confusion when working with CORS. β οΈ
Let's look at a practical example that triggers preflight:
// This WILL trigger a preflight request
fetch('https://api.external.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json', // β οΈ Triggers preflight!
'Authorization': 'Bearer abc123' // β οΈ Also triggers preflight!
},
body: JSON.stringify({
name: 'Alice',
email: 'alice@example.com'
})
});
// What actually happens:
// 1. Browser sends OPTIONS request (preflight)
// 2. Server responds with CORS headers allowing the request
// 3. Browser sends the actual POST request
// 4. Server responds with the data
Cross-Origin Request Examples with Fetch API
Let's explore several real-world scenarios to solidify your understanding. We'll examine both simple and preflighted requests using the modern fetch() API.
Example 1: Simple GET Request
// Page origin: https://mywebsite.com
// Target: https://api.example.com
fetch('https://api.example.com/public/articles')
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
console.log('Articles:', data);
})
.catch(error => {
console.error('CORS or network error:', error);
});
// This is a SIMPLE request because:
// β Method is GET
// β No custom headers
// β No special Content-Type
//
// The browser sends this directly without preflight.
// If api.example.com responds with proper CORS headers,
// your JavaScript can read the response.
Example 2: POST with JSON (Triggers Preflight)
// Page origin: https://app.mysite.com
// Target: https://api.external.com
fetch('https://api.external.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
},
body: JSON.stringify({
username: 'newuser',
email: 'newuser@example.com'
})
})
.then(response => response.json())
.then(data => {
console.log('User created:', data);
})
.catch(error => {
console.error('Failed:', error);
});
// This triggers PREFLIGHT because:
// β Content-Type is application/json (not in simple list)
// β Authorization header is custom (not in simple list)
//
// Browser flow:
// 1. Sends OPTIONS request with:
// Access-Control-Request-Method: POST
// Access-Control-Request-Headers: content-type, authorization
// 2. If server approves, sends actual POST
// 3. If server rejects, throws CORS error before POST is sent
Example 3: DELETE Request (Always Preflighted)
// Page origin: https://dashboard.myapp.com
// Target: https://api.myapp.com (different subdomain = cross-origin)
fetch('https://api.myapp.com/items/42', {
method: 'DELETE',
headers: {
'Authorization': 'Bearer token123'
}
})
.then(response => {
if (response.ok) {
console.log('Item deleted successfully');
} else {
console.error('Deletion failed:', response.status);
}
});
// This ALWAYS triggers preflight because:
// β DELETE method is not in the simple list (GET, HEAD, POST)
// β Authorization header is custom
//
// The OPTIONS preflight will ask:
// "Can I send a DELETE request with Authorization header?"
Cross-Origin Requests with XMLHttpRequest
While fetch() is the modern approach, you'll still encounter XMLHttpRequest in legacy code. The same CORS rules apply, but the API looks different:
// Page origin: https://client.example.com
// Target: https://api.example.com
var xhr = new XMLHttpRequest();
// The third parameter (true) makes this asynchronous
xhr.open('GET', 'https://api.example.com/data', true);
// Set custom header (this triggers preflight)
xhr.setRequestHeader('X-Custom-Header', 'value');
// Handle the response
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
console.log('Response:', xhr.responseText);
} else {
console.error('Request failed:', xhr.status);
}
};
// Handle CORS or network errors
xhr.onerror = function() {
console.error('CORS error or network failure');
};
// Send the request
xhr.send();
// Note: Because of X-Custom-Header, browser will:
// 1. Send OPTIONS preflight first
// 2. Check server's CORS response
// 3. Send actual GET if approved
π‘ Real-World Example: Authentication headers are the most common trigger for preflight requests. When building an API-driven application, almost every authenticated request will be preflighted because of the Authorization header. This is normal and expected behavior.
Credentials and Cross-Origin Requests
By default, cross-origin requests don't include credentials like cookies or HTTP authentication. This is a security measure. If you need to send cookies with a cross-origin request, you must explicitly opt in on the client side:
fetch('https://api.example.com/profile', {
credentials: 'include' // Include cookies in cross-origin request
})
.then(response => response.json())
.then(data => console.log(data));
// For XMLHttpRequest:
var xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/profile');
xhr.withCredentials = true; // Include cookies
xhr.send();
β οΈ Important: When you include credentials, the server must respond with specific CORS headers, and it cannot use wildcard (*) for allowed origins. We'll cover this in detail in the next section. β οΈ
Summary: The Origin Check Decision Tree
Let's consolidate what we've learned into a decision tree you can reference:
JavaScript makes a request
β
βΌ
Same origin?
ββββββ΄βββββ
YES NO (Cross-Origin)
β β
βΌ βΌ
Send Check request type
directly ββββββββββ΄βββββββββ
β β
SIMPLE NOT SIMPLE
β β
β βΌ
β Send OPTIONS
β preflight
β β
β βββββββ΄ββββββ
β OK DENIED
β β β
β β βΌ
β β Block request
β β Show CORS error
β β
βββββββββ¬ββββ
β
βΌ
Send actual request
β
βΌ
Check CORS response headers
ββββββ΄βββββ
VALID INVALID
β β
βΌ βΌ
Allow access Block access
to response Show CORS error
π Quick Reference: Simple vs Preflighted Requests
| Characteristic | Simple Request | Preflighted Request |
|---|---|---|
| π§ Methods | GET, HEAD, POST only | PUT, DELETE, PATCH, etc. |
| π Headers | Limited whitelist only | Custom headers allowed |
| π― Content-Type | form-urlencoded, multipart, text/plain | application/json, etc. |
| π Sent immediately | β Yes | β No (OPTIONS first) |
| β‘ Performance | Faster (one request) | Slower (two requests) |
| π Authorization header | β Triggers preflight | β Common use case |
Understanding the Practical Implications
Now that you understand what makes a request cross-origin and when preflight is triggered, you can make informed decisions in your applications:
β When designing APIs:
- Expect most modern API calls to trigger preflight (due to JSON and auth headers)
- Configure your server to handle OPTIONS requests properly
- Consider caching preflight responses to reduce overhead
β When building clients:
- Understand that preflight adds latency (expect two requests instead of one)
- Don't be alarmed by OPTIONS requests in your network tabβthey're normal
- Structure your requests to minimize unnecessary preflight triggers if performance is critical
β When debugging:
- Check if the request is same-origin or cross-origin first
- Identify whether it's simple or preflighted
- Look for the OPTIONS request in network tools
- Verify that all three origin components match exactly
π‘ Remember: The origin system isn't trying to make your life difficultβit's protecting users from malicious scripts. CORS is the controlled way to punch careful holes in this security boundary when you have legitimate cross-origin needs.
With this foundation in place, you now understand when and why browsers enforce CORS rules. In the next section, we'll dive deep into the actual CORS headers that flow between browsers and servers, showing you exactly how permission is granted or denied for cross-origin requests.
The CORS Request-Response Flow
Now that we understand what origins are and when requests are considered cross-origin, let's dive into the mechanics of how CORS actually works. When your JavaScript code makes a cross-origin request, an intricate dance begins between the browser and server, involving special HTTP headers that communicate permissions and restrictions. Understanding this flow is essential for both implementing CORS correctly and debugging it when things go wrong.
Simple Requests: The Basic CORS Flow
The most straightforward CORS scenario is what the specification calls a simple request. These are requests that meet specific criteria designed to be "safe" from a security perspectiveβroughly equivalent to what could be done with a traditional HTML form submission or image tag.
A request qualifies as simple when it meets all of these conditions:
π― Simple Request Criteria:
- Uses only GET, HEAD, or POST methods
- Contains only CORS-safe headers like Accept, Accept-Language, Content-Language, or Content-Type
- If Content-Type is present, it must be application/x-www-form-urlencoded, multipart/form-data, or text/plain
- No event listeners are registered on the XMLHttpRequest.upload object
- No ReadableStream is used in the request
When your JavaScript makes a simple request, here's what happens:
Browser Server
| |
| GET /api/data |
| Origin: https://example.com |
| ---------------------------------------->|
| |
| | (Server checks if
| | origin is allowed)
| |
| HTTP/1.1 200 OK |
| Access-Control-Allow-Origin: * |
| <----------------------------------------|
| |
| (Browser validates CORS headers |
| and allows JavaScript access) |
Let's break down what happens at each step. When the browser sends the request, it automatically adds an Origin header containing the origin of the requesting page. This happens without any intervention from your JavaScript codeβthe browser handles it completely.
// Client-side JavaScript making a simple request
fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('CORS error:', error));
// The browser automatically adds:
// Origin: https://yoursite.com
On the server side, the application examines the Origin header and decides whether to grant access. If approved, it responds with an Access-Control-Allow-Origin header:
// Server-side (Node.js/Express example)
app.get('/data', (req, res) => {
// Check the origin from the request
const origin = req.headers.origin;
// Allow specific origin
if (origin === 'https://yoursite.com') {
res.setHeader('Access-Control-Allow-Origin', origin);
}
// Or allow all origins (use cautiously!)
// res.setHeader('Access-Control-Allow-Origin', '*');
res.json({ message: 'Success!' });
});
When the response arrives back at the browser, the browser performs a critical validation step. It compares the Access-Control-Allow-Origin header value against the origin that made the request. Only if they match (or if the value is *, meaning all origins are allowed) does the browser allow your JavaScript code to access the response.
β οΈ Common Mistake 1: Trying to set CORS headers in client-side JavaScript. The browser ignores any Access-Control-* headers you try to set yourselfβthese must come from the server. β οΈ
π‘ Mental Model: Think of the Origin header as the browser saying "I'm from here" and the Access-Control-Allow-Origin header as the server replying "Yes, I allow requests from there." The browser acts as a security guard, only allowing the conversation to continue if the server explicitly permits it.
Preflight Requests: The OPTIONS Dance
Simple requests are convenient, but most modern web applications need to do more complex things: send JSON data, use custom headers, or make PUT or DELETE requests. These non-simple requests trigger what's called a preflight request.
A preflight is an automatic OPTIONS request that the browser sends before your actual request. Its purpose is to ask the server, "Would you accept the real request I'm about to send?" Only if the server responds positively does the browser then send your actual request.
Here's what triggers a preflight:
π§ Preflight Triggers:
- Methods other than GET, HEAD, or POST (like PUT, DELETE, PATCH)
- POST requests with Content-Type other than the three safe types
- Custom headers beyond the CORS-safe list (like Authorization, X-Custom-Header)
- Request configured to include credentials
The complete preflight flow looks like this:
Browser Server
| |
| OPTIONS /api/data |
| Origin: https://example.com |
| Access-Control-Request-Method: PUT |
| Access-Control-Request-Headers: Content-Type |
| ---------------------------------------->|
| |
| | (Server decides if
| | this is allowed)
| |
| HTTP/1.1 204 No Content |
| Access-Control-Allow-Origin: https://example.com |
| Access-Control-Allow-Methods: PUT, POST, GET |
| Access-Control-Allow-Headers: Content-Type |
| Access-Control-Max-Age: 86400 |
| <----------------------------------------|
| |
| (Browser validates preflight response) |
| |
| PUT /api/data |
| Origin: https://example.com |
| Content-Type: application/json |
| ---------------------------------------->|
| |
| HTTP/1.1 200 OK |
| Access-Control-Allow-Origin: https://example.com |
| <----------------------------------------|
Let's see this in action with code:
// Client-side: Making a request that triggers preflight
fetch('https://api.example.com/data', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Custom-Header': 'value'
},
body: JSON.stringify({ name: 'Alice' })
});
// The browser automatically sends an OPTIONS request first with:
// Access-Control-Request-Method: PUT
// Access-Control-Request-Headers: content-type,x-custom-header
The Access-Control-Request-Method header tells the server what HTTP method the actual request will use, while Access-Control-Request-Headers lists any non-standard headers that will be included.
On the server, you need to handle both the preflight OPTIONS request and the actual request:
// Server-side (Node.js/Express example)
app.options('/data', (req, res) => {
// Handle preflight request
res.setHeader('Access-Control-Allow-Origin', 'https://yoursite.com');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Custom-Header');
res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours
res.sendStatus(204); // No Content
});
app.put('/data', (req, res) => {
// Handle the actual request
res.setHeader('Access-Control-Allow-Origin', 'https://yoursite.com');
res.json({ message: 'Data updated' });
});
The Access-Control-Allow-Methods header tells the browser which HTTP methods are permitted, while Access-Control-Allow-Headers specifies which custom headers the actual request can include.
π― Key Principle: The preflight is a safety mechanism. It prevents your JavaScript from sending potentially dangerous requests to servers that haven't explicitly opted into cross-origin communication. Without preflight, malicious scripts could send DELETE requests or custom authentication headers to any server.
β οΈ Common Mistake 2: Forgetting to handle OPTIONS requests on the server. If your server doesn't respond properly to the preflight OPTIONS request, the actual request never gets sent. β οΈ
π‘ Pro Tip: Use your browser's Network tab to see preflight requests. They appear as OPTIONS requests and happen automatically before your actual request. If you see a failed OPTIONS request, that's why your actual request never fires.
Caching Preflight Responses
Sending an OPTIONS request before every single non-simple request would create significant performance overhead. Imagine making ten API calls in quick successionβwithout caching, that's twenty total requests (ten preflight + ten actual).
This is where Access-Control-Max-Age comes in. This response header tells the browser how long (in seconds) it can cache the preflight response and skip sending additional OPTIONS requests for identical requests.
// Server-side: Setting a long cache time for preflight
app.options('/api/*', (req, res) => {
res.setHeader('Access-Control-Allow-Origin', 'https://yoursite.com');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '7200'); // 2 hours
res.sendStatus(204);
});
With a max-age of 7200 seconds (2 hours), the browser will remember that this server accepts PUT requests with Content-Type and Authorization headers from your origin. For the next two hours, identical requests won't trigger another preflightβthe browser will immediately send the actual request.
π€ Did you know? Different browsers have different maximum values they'll respect for Access-Control-Max-Age. Chrome caps it at 2 hours (7200 seconds), while Firefox allows up to 24 hours. Setting a value higher than the browser's limit simply causes the browser to use its maximum.
π‘ Real-World Example: A single-page application making frequent API calls can benefit enormously from preflight caching. If your app fetches user data every 30 seconds, setting a max-age of 3600 (1 hour) means you'll only send one preflight per hour instead of 120.
Important considerations about preflight caching:
π§ Preflight Cache Behavior:
- The cache is per-origin, per-URL, per-method, and per-headers combination
- Changing any header in your request invalidates the cache for that combination
- The cache is cleared when the browser restarts (in most browsers)
- The cache is not shared across browser tabs or windows
- Each unique combination of requested method and headers gets its own cache entry
β οΈ Common Mistake 3: Setting a very short max-age or omitting it entirely. Without a max-age header, browsers may not cache the preflight at all, doubling your request count unnecessarily. β οΈ
Credentialed Requests: Cookies and Authentication
By default, cross-origin requests made with fetch() or XMLHttpRequest don't include cookies, HTTP authentication, or client-side SSL certificates. This is a security featureβbrowsers don't want your cookies being sent to arbitrary third-party servers.
However, many applications need to send credentials cross-origin. Perhaps your frontend is hosted at app.example.com and your API is at api.example.com, and you need authentication cookies to go with each request.
This is where credentialed requests come in. To send credentials, you must explicitly enable them on both the client and server side:
// Client-side: Enabling credentials
fetch('https://api.example.com/user-data', {
method: 'GET',
credentials: 'include', // This is the key line
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => console.log(data));
The credentials: 'include' option tells the browser to send cookies and other credentials with the request. But the browser won't actually send them unless the server explicitly permits it.
The server must respond with the Access-Control-Allow-Credentials header set to true:
// Server-side: Allowing credentialed requests
app.get('/user-data', (req, res) => {
// IMPORTANT: Cannot use '*' with credentials
res.setHeader('Access-Control-Allow-Origin', 'https://yoursite.com');
res.setHeader('Access-Control-Allow-Credentials', 'true');
// Now cookies from the request are available
const sessionId = req.cookies.sessionId;
res.json({ message: 'User data', sessionId });
});
π― Key Principle: When credentials are involved, the server CANNOT use Access-Control-Allow-Origin: *. It must specify the exact origin. This is a critical security restrictionβallowing credentials from any origin would be extremely dangerous.
The credentialed preflight flow has an additional requirement:
Browser Server
| |
| OPTIONS /api/user-data |
| Origin: https://example.com |
| Access-Control-Request-Method: POST |
| ---------------------------------------->|
| |
| HTTP/1.1 204 No Content |
| Access-Control-Allow-Origin: https://example.com |
| Access-Control-Allow-Methods: POST |
| Access-Control-Allow-Credentials: true |
| <----------------------------------------|
| |
| POST /api/user-data |
| Origin: https://example.com |
| Cookie: sessionId=abc123 |
| ---------------------------------------->|
| |
| HTTP/1.1 200 OK |
| Access-Control-Allow-Origin: https://example.com |
| Access-Control-Allow-Credentials: true |
| <----------------------------------------|
Notice that Access-Control-Allow-Credentials: true must be present in both the preflight response and the actual response.
β οΈ Common Mistake 4: Trying to use Access-Control-Allow-Origin: * with credentialed requests. The browser will reject this combination and your request will fail with a CORS error. You must specify the exact origin. β οΈ
β Wrong thinking: "I'll just allow all origins and all credentials to make my API flexible."
β Correct thinking: "Credentialed requests require explicit origin specification. I'll maintain a whitelist of allowed origins and validate against it."
Here's a more robust server-side pattern for handling credentialed requests with origin validation:
// Server-side: Proper origin validation for credentials
const ALLOWED_ORIGINS = [
'https://app.example.com',
'https://staging.example.com',
'http://localhost:3000' // For development
];
app.use((req, res, next) => {
const origin = req.headers.origin;
// Check if the origin is in our whitelist
if (ALLOWED_ORIGINS.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
// Handle preflight
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
π‘ Pro Tip: For development, you might need to allow http://localhost:3000 or similar origins. Make sure these are removed or restricted in production builds.
Exposed Headers: Accessing Response Metadata
By default, when your JavaScript receives a cross-origin response, it can only read a limited set of "safe" response headers:
- Cache-Control
- Content-Language
- Content-Type
- Expires
- Last-Modified
- Pragma
If the server sends other headersβlike custom headers for pagination, rate limiting, or authentication tokensβyour JavaScript code won't be able to access them unless the server explicitly exposes them.
The Access-Control-Expose-Headers header tells the browser which additional headers should be made available to JavaScript:
// Server-side: Exposing custom headers
app.get('/data', (req, res) => {
res.setHeader('Access-Control-Allow-Origin', 'https://yoursite.com');
res.setHeader('Access-Control-Expose-Headers', 'X-Total-Count, X-Rate-Limit');
res.setHeader('X-Total-Count', '150');
res.setHeader('X-Rate-Limit', '100');
res.json({ items: [...] });
});
// Client-side: Accessing exposed headers
fetch('https://api.example.com/data')
.then(response => {
const totalCount = response.headers.get('X-Total-Count');
const rateLimit = response.headers.get('X-Rate-Limit');
console.log(`Total: ${totalCount}, Rate limit: ${rateLimit}`);
return response.json();
});
Without the Access-Control-Expose-Headers line, trying to read X-Total-Count or X-Rate-Limit would return null in your JavaScript code, even though the server sent them.
π‘ Real-World Example: APIs often use custom headers for pagination metadata. An API might return X-Total-Pages, X-Current-Page, and X-Per-Page headers. To make these accessible to your frontend, the API must include them in Access-Control-Expose-Headers.
The Complete CORS Header Reference
Let's consolidate everything we've learned into a comprehensive reference:
π Quick Reference Card: CORS Headers
| Header | Direction | Purpose | Example Value |
|---|---|---|---|
| π΅ Origin | Request | Browser identifies requesting origin | https://example.com |
| π΅ Access-Control-Request-Method | Request (preflight) | Declares method for actual request | PUT |
| π΅ Access-Control-Request-Headers | Request (preflight) | Lists non-standard headers for actual request | Content-Type, Authorization |
| π’ Access-Control-Allow-Origin | Response | Specifies allowed origin(s) | https://example.com or * |
| π’ Access-Control-Allow-Methods | Response | Lists allowed HTTP methods | GET, POST, PUT, DELETE |
| π’ Access-Control-Allow-Headers | Response | Lists allowed request headers | Content-Type, Authorization |
| π’ Access-Control-Allow-Credentials | Response | Permits credentialed requests | true |
| π’ Access-Control-Expose-Headers | Response | Makes headers accessible to JS | X-Total-Count |
| π’ Access-Control-Max-Age | Response | Sets preflight cache duration | 7200 (in seconds) |
Putting It All Together
Let's look at a complete, production-ready example that handles all aspects of CORS:
// Server-side: Comprehensive CORS middleware (Node.js/Express)
const express = require('express');
const app = express();
const ALLOWED_ORIGINS = [
'https://app.example.com',
'https://staging.example.com',
process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null
].filter(Boolean);
const corsMiddleware = (req, res, next) => {
const origin = req.headers.origin;
// Check if origin is allowed
if (ALLOWED_ORIGINS.includes(origin)) {
// Set basic CORS headers
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
// Set allowed methods
res.setHeader(
'Access-Control-Allow-Methods',
'GET, POST, PUT, DELETE, PATCH, OPTIONS'
);
// Set allowed headers
res.setHeader(
'Access-Control-Allow-Headers',
'Content-Type, Authorization, X-Requested-With'
);
// Expose custom headers
res.setHeader(
'Access-Control-Expose-Headers',
'X-Total-Count, X-Page-Count, X-Rate-Limit'
);
// Set preflight cache duration (2 hours)
res.setHeader('Access-Control-Max-Age', '7200');
}
// Handle preflight requests
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
};
// Apply middleware globally
app.use(corsMiddleware);
// Example route that uses all CORS features
app.get('/api/items', (req, res) => {
// Custom headers for pagination
res.setHeader('X-Total-Count', '500');
res.setHeader('X-Page-Count', '50');
res.json({
items: ['item1', 'item2', 'item3'],
page: 1
});
});
app.post('/api/items', (req, res) => {
// This route handles credentialed POST requests
// with custom headers, all thanks to our CORS middleware
res.json({ success: true, id: '123' });
});
app.listen(3001, () => {
console.log('API server running on port 3001');
});
And here's the corresponding client-side code that takes advantage of all these CORS features:
// Client-side: Complete CORS usage example
class APIClient {
constructor(baseURL) {
this.baseURL = baseURL;
}
// Helper method that handles all requests
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
const config = {
credentials: 'include', // Always include cookies
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
};
try {
const response = await fetch(url, config);
// Access exposed headers
const totalCount = response.headers.get('X-Total-Count');
const pageCount = response.headers.get('X-Page-Count');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Return both data and metadata
return {
data,
metadata: {
totalCount: totalCount ? parseInt(totalCount) : null,
pageCount: pageCount ? parseInt(pageCount) : null
}
};
} catch (error) {
if (error.name === 'TypeError' && error.message.includes('fetch')) {
// This is likely a CORS error
console.error('CORS error - check server configuration');
}
throw error;
}
}
async getItems() {
return this.request('/api/items');
}
async createItem(itemData) {
return this.request('/api/items', {
method: 'POST',
body: JSON.stringify(itemData)
});
}
}
// Usage
const api = new APIClient('https://api.example.com');
api.getItems()
.then(({ data, metadata }) => {
console.log('Items:', data.items);
console.log('Total items:', metadata.totalCount);
})
.catch(error => console.error('Error:', error));
π‘ Remember: The complexity of CORS is intentionalβit's a security feature, not a convenience feature. Each layer of the protocol (preflight, credentials, exposed headers) exists to protect users while enabling legitimate cross-origin communication.
π§ Mnemonic: For remembering which CORS headers are request vs. response, think "Request headers ASK, Response headers ANSWER." Access-Control-Request-* headers ask permission, Access-Control-Allow-* headers answer with permission.
Summary of the Request-Response Flow
Let's recap the key flows we've covered:
π― Simple Request Flow:
- Browser adds Origin header automatically
- Server responds with Access-Control-Allow-Origin
- Browser validates and allows/denies access
π― Preflight Request Flow:
- Browser sends OPTIONS with Access-Control-Request-* headers
- Server responds with Access-Control-Allow-* headers
- Browser validates preflight response
- If approved, browser sends actual request
- Server responds with Access-Control-Allow-Origin
- Browser validates final response and allows access
π― Credentialed Request Requirements:
- Client sets
credentials: 'include' - Server cannot use wildcard origin
- Server must set Access-Control-Allow-Credentials: true
- All other CORS requirements still apply
Understanding these flows is crucial because when CORS errors occur (and they will), you need to know which step in the process is failing. Is the preflight being rejected? Is the origin not matching? Are credentials being blocked? With this knowledge of the complete request-response lifecycle, you're equipped to diagnose and fix these issues.
In the next section, we'll take these concepts and explore practical implementation patterns across different frameworks and real-world scenarios. Understanding the theory is essential, but knowing how to implement CORS correctly in production environments is where the rubber meets the road.
Implementing CORS: Client and Server Patterns
Now that you understand how CORS works conceptually, it's time to get your hands dirty with actual implementation. In this section, we'll explore the practical patterns for both requesting cross-origin resources from the client and configuring your server to handle those requests securely. Think of this as bridging the gap between theory and production codeβthe patterns you'll learn here form the foundation of nearly every modern web application that communicates across origins.
Client-Side CORS: Making Requests That Play Nice
When you make cross-origin requests from the browser, you're essentially asking permission to access someone else's resources. The way you craft these requests matters enormously. Let's start with the Fetch API, the modern standard for making HTTP requests in JavaScript.
Basic Fetch Requests with CORS
The simplest cross-origin request looks deceptively straightforward:
// Simple cross-origin GET request
fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('CORS error:', error));
This simple request (as we learned in the previous section) won't trigger a preflight because it uses a basic method (GET) and doesn't include custom headers. The browser automatically includes an Origin header, and if the server responds with appropriate CORS headers, you'll get your data. If not, the browser blocks the response and you'll see that dreaded CORS error in the console.
Configuring Fetch for Credentialed Requests
Things get more interesting when you need to include credentials like cookies, HTTP authentication, or client certificates. By default, fetch doesn't include credentials in cross-origin requests for security reasons. You must explicitly opt in:
// Cross-origin request with credentials (cookies, auth headers)
fetch('https://api.example.com/user/profile', {
method: 'GET',
credentials: 'include', // This is the key setting
headers: {
'Accept': 'application/json'
}
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
})
.then(data => console.log('User data:', data))
.catch(error => console.error('Request failed:', error));
π― Key Principle: The credentials option has three possible values:
omit: Never send credentials (default for cross-origin)same-origin: Only send credentials for same-origin requests (default behavior)include: Always send credentials, even cross-origin
β οΈ Common Mistake 1: Setting credentials: 'include' on the client but forgetting that the server MUST respond with Access-Control-Allow-Credentials: true AND cannot use Access-Control-Allow-Origin: *. The origin must be explicitly specified. Without both server-side settings, the browser will block the response even if it arrived successfully. β οΈ
Custom Headers and Preflight Triggers
When you add custom headers to your requestsβparticularly common with authentication tokensβyou're almost certainly triggering a preflight request:
// This will trigger a preflight OPTIONS request
fetch('https://api.example.com/protected-data', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
'X-Custom-Header': 'custom-value'
},
body: JSON.stringify({
query: 'some data'
})
})
.then(response => response.json())
.then(data => console.log(data));
This request triggers a preflight because:
- It uses POST with a JSON body
- It includes an
Authorizationheader (not a simple header) - It includes a custom
X-Custom-Header
The browser will automatically send the preflight OPTIONS request first. Your job on the client is just to make the request correctlyβthe browser handles the preflight mechanics automatically.
π‘ Pro Tip: You can inspect the preflight OPTIONS request in your browser's Network tab. Look for the request with the same URL but the OPTIONS methodβit happens before your actual request. This is invaluable for debugging CORS issues.
XMLHttpRequest: The Legacy Approach
While fetch is the modern standard, you'll still encounter XMLHttpRequest (XHR) in legacy code:
var xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/data', true);
xhr.withCredentials = true; // Equivalent to credentials: 'include'
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
var data = JSON.parse(xhr.responseText);
console.log('Data:', data);
}
};
xhr.onerror = function() {
console.error('CORS request failed');
};
xhr.send();
The key property here is withCredentials, which serves the same purpose as fetch's credentials: 'include'. The same server-side requirements apply.
Server-Side CORS: Granting Permission
The client can only requestβthe server has all the power to grant or deny access. Let's explore how to configure CORS headers across popular server frameworks. The patterns are remarkably similar across platforms because they're all implementing the same HTTP header-based protocol.
Node.js with Express: The JavaScript Server
Express is one of the most popular Node.js frameworks, and configuring CORS is straightforward with the cors middleware:
const express = require('express');
const cors = require('cors');
const app = express();
// Basic CORS setup - allows all origins (β οΈ development only!)
app.use(cors());
// More restrictive: specific origin
const corsOptions = {
origin: 'https://myapp.com',
credentials: true, // Allow cookies/credentials
optionsSuccessStatus: 200 // Some legacy browsers choke on 204
};
app.use(cors(corsOptions));
// Dynamic origin validation
const allowedOrigins = [
'https://myapp.com',
'https://staging.myapp.com',
'http://localhost:3000'
];
const dynamicCorsOptions = {
origin: function(origin, callback) {
// Allow requests with no origin (mobile apps, Postman, etc.)
if (!origin) return callback(null, true);
if (allowedOrigins.indexOf(origin) === -1) {
const msg = 'The CORS policy for this site does not allow access from the specified Origin.';
return callback(new Error(msg), false);
}
return callback(null, true);
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
exposedHeaders: ['X-Total-Count', 'X-Page-Number'], // Custom headers clients can read
maxAge: 86400 // Preflight cache duration in seconds (24 hours)
};
app.use(cors(dynamicCorsOptions));
// Specific route with different CORS settings
app.get('/public-api', cors({ origin: '*' }), (req, res) => {
res.json({ message: 'This is publicly accessible' });
});
// Protected endpoint
app.post('/api/user/update', (req, res) => {
// CORS already handled by middleware
res.json({ success: true });
});
app.listen(3001, () => {
console.log('Server running on port 3001');
});
Let's break down what's happening here:
The origin function is particularly powerful. It receives the requesting origin (from the Origin header) and a callback. You can implement any logic you wantβcheck against a database of allowed origins, validate based on environment, or even do async operations before deciding. This pattern gives you complete control.
The credentials: true option tells the middleware to set Access-Control-Allow-Credentials: true. Remember, when you enable this, you cannot use a wildcard origin.
The allowedHeaders array specifies which custom headers the client can send. These appear in the Access-Control-Allow-Headers response header during preflight.
The exposedHeaders array specifies custom response headers that JavaScript can read. By default, browsers only expose simple response headers (like Content-Type). If you want your client code to read custom headers like X-Total-Count, you must explicitly expose them.
The maxAge setting tells browsers how long they can cache the preflight response, reducing the number of OPTIONS requests.
π‘ Real-World Example: In a microservices architecture, you might have multiple frontend applications (web app, admin panel, mobile web) each on different subdomains. The dynamic origin validation pattern lets you maintain a whitelist that's easy to update without redeploying, especially if you store the allowed origins in environment variables or a configuration service.
Manual CORS Headers Without Middleware
Sometimes you need more control or can't use middleware. Here's how to set CORS headers manually in Express:
app.use((req, res, next) => {
const origin = req.headers.origin;
const allowedOrigins = ['https://myapp.com', 'http://localhost:3000'];
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Max-Age', '86400');
// Handle preflight
if (req.method === 'OPTIONS') {
return res.status(200).end();
}
next();
});
This manual approach gives you complete transparency about what's happening. Notice the explicit preflight handlingβwhen the browser sends an OPTIONS request, you respond immediately with a 200 status and the CORS headers, without executing the route handler.
Python with Flask: Cross-Language Patterns
The concepts translate directly to other languages. Here's Flask with the flask-cors extension:
from flask import Flask, jsonify, request
from flask_cors import CORS, cross_origin
import os
app = Flask(__name__)
## Basic CORS - allows all origins (development only)
## CORS(app)
## Production configuration with specific origins
allowed_origins = [
'https://myapp.com',
'https://staging.myapp.com',
'http://localhost:3000'
]
## Add localhost for development
if os.getenv('FLASK_ENV') == 'development':
allowed_origins.append('http://localhost:*')
CORS(app,
origins=allowed_origins,
supports_credentials=True,
allow_headers=['Content-Type', 'Authorization', 'X-Custom-Header'],
expose_headers=['X-Total-Count'],
max_age=86400)
## Route-specific CORS with decorator
@app.route('/public-data')
@cross_origin(origins='*') # Public endpoint
def public_data():
return jsonify({'data': 'publicly accessible'})
## Protected endpoint inherits app-level CORS
@app.route('/api/user/profile', methods=['GET', 'POST'])
def user_profile():
# CORS already configured at app level
return jsonify({'user': 'profile data'})
## Manual CORS headers for fine-grained control
@app.route('/custom-cors')
def custom_cors():
response = jsonify({'data': 'custom CORS'})
origin = request.headers.get('Origin')
if origin in allowed_origins:
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
return response
if __name__ == '__main__':
app.run(port=5000, debug=True)
The pattern is nearly identical to Express: configure allowed origins, enable credentials if needed, specify allowed/exposed headers, and set cache duration. The decorator pattern (@cross_origin()) is particularly Pythonic, allowing route-specific overrides.
Handling Multiple Origins Dynamically
One of the most common real-world scenarios is supporting multiple legitimate origins. You can't use Access-Control-Allow-Origin: * with credentials, so you need a strategy for dynamically setting the allowed origin based on the request.
The Pattern: Whitelist Validation
Here's the canonical pattern that works across all frameworks:
Incoming Request
|
v
[Extract Origin header]
|
v
[Check against whitelist]
|
+-- Origin allowed?
| |
| +-- YES --> [Set Access-Control-Allow-Origin to that specific origin]
| | |
| | v
| | [Set other CORS headers]
| | |
| | v
| | [Process request]
| |
| +-- NO --> [Don't set CORS headers]
| |
| v
| [Browser blocks response]
v
π― Key Principle: The Access-Control-Allow-Origin header can only contain one origin or the wildcard. You cannot set it to multiple origins separated by commas. The dynamic patternβchecking the request's Origin and echoing it back if allowedβsolves this limitation elegantly.
Environment-Based Origin Configuration
A robust production pattern separates allowed origins by environment:
// config.js
const environments = {
development: {
allowedOrigins: [
'http://localhost:3000',
'http://localhost:3001',
'http://127.0.0.1:3000'
],
allowWildcard: false // Still explicit even in dev
},
staging: {
allowedOrigins: [
'https://staging.myapp.com',
'https://staging-admin.myapp.com'
],
allowWildcard: false
},
production: {
allowedOrigins: [
'https://myapp.com',
'https://www.myapp.com',
'https://admin.myapp.com'
],
allowWildcard: false
}
};
const env = process.env.NODE_ENV || 'development';
const config = environments[env];
module.exports = config;
// In your server file
const corsConfig = require('./config');
const corsOptions = {
origin: function(origin, callback) {
// No origin = non-browser request (mobile app, server-to-server)
if (!origin) return callback(null, true);
if (corsConfig.allowedOrigins.includes(origin)) {
callback(null, true);
} else {
console.warn(`CORS blocked origin: ${origin}`);
callback(new Error('Not allowed by CORS'));
}
},
credentials: true
};
app.use(cors(corsOptions));
This pattern keeps your CORS configuration maintainable and secure across all environments. Notice the logging of blocked originsβinvaluable for debugging when a legitimate origin is accidentally omitted.
π‘ Pro Tip: Store production allowed origins in environment variables rather than committing them to code. This makes it easy to add new origins without code changes: process.env.ALLOWED_ORIGINS.split(',') gives you an array from a comma-separated environment variable.
Development vs Production CORS Strategies
CORS configuration needs differ dramatically between development and production. Let's explore patterns for each.
Development: Convenience with Awareness
During development, you want fast iteration without CORS blocking your every move. Common approaches:
1. CORS Proxy for Development
Many developers use a local proxy to avoid CORS during development:
// In your package.json (Create React App)
{
"proxy": "http://localhost:3001"
}
With this setup, requests to /api/data from your React app (on port 3000) automatically proxy to http://localhost:3001/api/data, making them same-origin from the browser's perspective.
2. Permissive Development Server
// Development-only permissive CORS
if (process.env.NODE_ENV === 'development') {
app.use(cors({
origin: true, // Reflects the request origin
credentials: true
}));
} else {
app.use(cors(strictProductionOptions));
}
β οΈ Common Mistake 2: Using Access-Control-Allow-Origin: * in development and forgetting to test with real CORS constraints before deploying to production. This leads to "it works on my machine" problems when suddenly credentials don't work in production. Always test with realistic CORS settings before deploying. β οΈ
Production: Security First
Production CORS configuration should be as restrictive as possible while supporting your legitimate use cases:
const productionCorsOptions = {
origin: function(origin, callback) {
const allowedOrigins = process.env.ALLOWED_ORIGINS.split(',');
// Reject requests with no origin in production (optional, based on needs)
if (!origin) {
return callback(new Error('Origin required'), false);
}
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
// Log for security monitoring
console.warn(`[SECURITY] Blocked CORS request from: ${origin}`);
callback(new Error('Not allowed by CORS'), false);
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'], // Explicit methods only
allowedHeaders: ['Content-Type', 'Authorization'], // Minimal set
exposedHeaders: ['X-Total-Count'],
maxAge: 86400, // Cache preflight for 24 hours
preflightContinue: false, // Don't pass to next handler
optionsSuccessStatus: 204
};
if (process.env.NODE_ENV === 'production') {
app.use(cors(productionCorsOptions));
}
Security considerations:
- β Never use wildcards with credentials
- β Explicitly list allowed origins, no regex wildcards
- β Minimize allowed headers and methods
- β Log rejected CORS requests for security monitoring
- β Use HTTPS origins only in production (reject HTTP)
π Security Note: Consider validating that allowed origins use HTTPS in production:
origin: function(origin, callback) {
if (!origin) return callback(new Error('Origin required'), false);
// In production, only allow HTTPS origins
if (process.env.NODE_ENV === 'production' && !origin.startsWith('https://')) {
console.warn(`[SECURITY] Rejected non-HTTPS origin: ${origin}`);
return callback(new Error('HTTPS required'), false);
}
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'), false);
}
}
Complete Working Example: Full-Stack CORS Setup
Let's put it all together with a complete example showing both client and server with realistic scenarios.
Server: Express API with JWT Authentication
const express = require('express');
const cors = require('cors');
const jwt = require('jsonwebtoken');
const cookieParser = require('cookie-parser');
const app = express();
app.use(express.json());
app.use(cookieParser());
// CORS configuration
const allowedOrigins = [
'https://myapp.com',
'http://localhost:3000' // Development frontend
];
const corsOptions = {
origin: function(origin, callback) {
// Allow requests with no origin (mobile apps, Postman)
if (!origin) return callback(null, true);
if (allowedOrigins.indexOf(origin) !== -1) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true, // Allow cookies
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['X-Total-Count'],
maxAge: 86400
};
app.use(cors(corsOptions));
// Public endpoint - no authentication required
app.get('/api/public', (req, res) => {
res.json({ message: 'Public data accessible by anyone' });
});
// Login endpoint - sets HTTP-only cookie
app.post('/api/login', (req, res) => {
const { username, password } = req.body;
// Simplified auth (use real auth in production!)
if (username === 'user' && password === 'pass') {
const token = jwt.sign({ username }, 'secret-key', { expiresIn: '1h' });
// Set HTTP-only cookie
res.cookie('auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
sameSite: 'none', // Required for cross-origin cookies
maxAge: 3600000 // 1 hour
});
res.json({ success: true, username });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
// Protected endpoint - requires authentication
app.get('/api/user/profile', (req, res) => {
const token = req.cookies.auth_token;
if (!token) {
return res.status(401).json({ error: 'Not authenticated' });
}
try {
const decoded = jwt.verify(token, 'secret-key');
res.json({
username: decoded.username,
profile: { /* user data */ }
});
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
});
// Endpoint with custom response header
app.get('/api/items', (req, res) => {
const items = [/* array of items */];
// This header is exposed via CORS config
res.setHeader('X-Total-Count', items.length);
res.json(items);
});
app.listen(3001, () => {
console.log('API server running on http://localhost:3001');
});
Client: React Application Making CORS Requests
// api.js - Centralized API client
const API_BASE = process.env.REACT_APP_API_URL || 'http://localhost:3001';
class ApiClient {
// Helper method for all requests
async request(endpoint, options = {}) {
const config = {
credentials: 'include', // Always include cookies
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
};
try {
const response = await fetch(`${API_BASE}${endpoint}`, config);
// Read custom headers if needed
const totalCount = response.headers.get('X-Total-Count');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Return data with metadata if custom headers exist
return totalCount ? { data, totalCount } : data;
} catch (error) {
// CORS errors appear as network errors
if (error.message === 'Failed to fetch') {
console.error('Possible CORS error or network issue');
}
throw error;
}
}
// Public endpoint - no auth required
async getPublicData() {
return this.request('/api/public');
}
// Login - sends credentials, receives cookie
async login(username, password) {
return this.request('/api/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
}
// Protected endpoint - cookie sent automatically
async getUserProfile() {
return this.request('/api/user/profile');
}
// Request with Bearer token (alternative to cookies)
async getDataWithToken(token) {
return this.request('/api/protected', {
headers: {
'Authorization': `Bearer ${token}`
}
});
}
// Get items with total count from header
async getItems() {
return this.request('/api/items');
}
}
export default new ApiClient();
// Usage in React component
import React, { useEffect, useState } from 'react';
import api from './api';
function UserProfile() {
const [profile, setProfile] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
api.getUserProfile()
.then(data => setProfile(data))
.catch(err => {
setError(err.message);
// Handle CORS or auth errors
if (err.message.includes('401')) {
// Redirect to login
}
});
}, []);
if (error) return <div>Error: {error}</div>;
if (!profile) return <div>Loading...</div>;
return <div>Welcome, {profile.username}!</div>;
}
This complete example demonstrates:
- π§ Server-side origin validation with whitelist
- π Credential-based authentication using HTTP-only cookies
- π Custom response headers properly exposed
- π― Client-side credential inclusion in all requests
- π§ Centralized API client pattern for consistency
- π SameSite cookie configuration for cross-origin contexts
π‘ Real-World Example: This pattern is exactly what you'd use for a typical SPA (Single Page Application) with a separate API backend. The frontend might be deployed to app.example.com while the API runs on api.example.com. The CORS configuration allows them to communicate securely while the browser enforces the cross-origin restrictions.
Framework-Specific Quick Patterns
Here's a quick reference for common frameworks:
π Quick Reference Card: CORS by Framework
| Framework | π§ Package/Module | π‘ Basic Setup | π― Key Option |
|---|---|---|---|
| Express (Node) | cors |
app.use(cors(options)) |
origin function for dynamic validation |
| Flask (Python) | flask-cors |
CORS(app, origins=[...]) |
supports_credentials=True |
| Django (Python) | django-cors-headers |
Add to MIDDLEWARE | CORS_ALLOWED_ORIGINS in settings |
| ASP.NET Core | Built-in | services.AddCors() |
WithOrigins() policy builder |
| Spring Boot (Java) | @CrossOrigin |
Annotation on controller | allowCredentials = "true" |
| Laravel (PHP) | fruitcake/laravel-cors |
Config in cors.php |
'supports_credentials' => true |
π€ Did you know? The CORS specification was finalized in 2014, but browsers implemented it progressively starting around 2010. Before CORS, developers used JSONP (JSON with Padding) or server-side proxies to work around the Same-Origin Policy. CORS made cross-origin requests secure and standardized.
Testing Your CORS Configuration
Before deploying, verify your CORS setup works correctly:
Manual Testing Steps:
- π§ͺ Check preflight response: Use browser DevTools Network tab, look for OPTIONS request
- π Verify headers: Confirm
Access-Control-Allow-Origin,Access-Control-Allow-Credentials, etc. are present - π― Test with credentials: Ensure cookies/auth headers work when
credentials: 'include'is set - β οΈ Test wrong origin: Verify that requests from non-whitelisted origins are actually blocked
- π Test in production-like environment: Use actual domain names, not just localhost
Using curl for CORS testing:
## Test preflight request
curl -X OPTIONS \
-H "Origin: https://myapp.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization" \
-v \
https://api.example.com/endpoint
## Look for Access-Control-* headers in response
## Test actual request
curl -X POST \
-H "Origin: https://myapp.com" \
-H "Content-Type: application/json" \
-d '{"data": "test"}' \
-v \
https://api.example.com/endpoint
β Correct thinking: CORS is a security feature that protects users. Configure it thoughtfully to allow legitimate cross-origin access while blocking unauthorized origins.
β Wrong thinking: CORS is annoying and I'll just disable it with wildcards everywhere. This leaves your API vulnerable to unauthorized access and CSRF attacks.
With these implementation patterns, you're equipped to configure CORS correctly in virtually any client-server scenario. The key is understanding that the client requests permission and the server grants it through HTTP headers, with the browser acting as the enforcer of the policy. In the next section, we'll explore common mistakes and how to debug them when things go wrong.
Common CORS Pitfalls and Debugging
CORS errors are among the most frustrating challenges developers encounter when building web applications. A request that works perfectly from tools like Postman suddenly fails in the browser with cryptic error messages. Understanding why these failures occur and how to diagnose them quickly is essential for any modern web developer. In this section, we'll explore the most common CORS pitfalls and equip you with practical debugging strategies to resolve these issues efficiently.
The Wildcard Origin Problem with Credentials
One of the most confusing CORS pitfalls involves the wildcard origin (*) and its interaction with credentialed requests. This combination is explicitly forbidden by the CORS specification, yet developers frequently attempt to use it, leading to frustrating failures.
When you configure a server to respond with Access-Control-Allow-Origin: *, you're telling browsers that any origin can access the resource. This seems convenient, but there's a critical limitation: you cannot use the wildcard origin when your request includes credentials (cookies, HTTP authentication, or TLS client certificates).
π― Key Principle: Browsers enforce a strict rule that credentialed requests require an explicit origin in the Access-Control-Allow-Origin header. The wildcard is not permitted.
Here's what happens when you violate this rule:
// Client-side code attempting to send credentials
fetch('https://api.example.com/user-data', {
method: 'GET',
credentials: 'include', // This sends cookies!
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.catch(error => console.error('CORS error:', error));
## Server-side configuration (Flask example) - THIS WILL FAIL!
from flask import Flask, jsonify
from flask_cors import CORS
app = Flask(__name__)
@app.route('/user-data')
def user_data():
response = jsonify({'username': 'john_doe'})
response.headers['Access-Control-Allow-Origin'] = '*'
response.headers['Access-Control-Allow-Credentials'] = 'true'
return response
β οΈ Common Mistake 1: Setting Access-Control-Allow-Origin: * alongside Access-Control-Allow-Credentials: true β οΈ
The browser will reject this configuration with an error like:
The value of the 'Access-Control-Allow-Origin' header in the response
must not be the wildcard '*' when the request's credentials mode is 'include'.
Why this restriction exists: The security model prevents a malicious site from making credentialed requests on behalf of a user to arbitrary APIs. If wildcard origins worked with credentials, any website could potentially access your authenticated sessions on other sites, creating a massive security vulnerability.
β Correct approach: Explicitly specify the allowed origin:
## Server-side configuration - CORRECT VERSION
from flask import Flask, jsonify, request
app = Flask(__name__)
@app.route('/user-data')
def user_data():
origin = request.headers.get('Origin')
allowed_origins = ['https://app.example.com', 'https://staging.example.com']
response = jsonify({'username': 'john_doe'})
if origin in allowed_origins:
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
return response
π‘ Pro Tip: When handling multiple allowed origins, dynamically echo back the requesting origin if it's on your allowlist. This gives you flexibility while maintaining security.
The Silent Preflight Failure
Another perplexing issue occurs when browsers send preflight requests (OPTIONS requests) that fail silently, causing your actual request to never execute. This commonly happens with PUT, DELETE, or PATCH requests, or when using custom headers.
Let's visualize the preflight flow and where it can break:
Browser Server
| |
| OPTIONS /api/resource --------------> |
| (Preflight Request) |
| Origin: https://app.example.com |
| Access-Control-Request-Method: DELETE |
| Access-Control-Request-Headers: X-Auth |
| |
| <------------- 200 OK (or 404/500!) |
| β Missing required headers |
| |
| β ACTUAL REQUEST BLOCKED |
| (never sent!) |
β οΈ Common Mistake 2: Forgetting to handle OPTIONS requests or missing required preflight response headers β οΈ
Here's a scenario that causes silent failures:
// Client code that triggers a preflight
fetch('https://api.example.com/resource', {
method: 'DELETE',
headers: {
'X-Custom-Auth': 'token123',
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.catch(error => console.error('Failed:', error));
If your server doesn't properly respond to the preflight OPTIONS request, the DELETE request never gets sent. The server might look like this:
// Express.js server - INCORRECT (missing preflight handling)
const express = require('express');
const app = express();
app.delete('/resource', (req, res) => {
// This code never executes because preflight fails!
res.json({ success: true });
});
The browser sends an OPTIONS request, but your server returns 404 because you haven't defined a route handler for OPTIONS requests. The browser sees this failure and blocks the actual DELETE request.
β Correct approach: Handle OPTIONS requests explicitly:
// Express.js server - CORRECT VERSION
const express = require('express');
const app = express();
const allowedOrigins = ['https://app.example.com'];
// Middleware to handle CORS preflight
app.options('/resource', (req, res) => {
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, PUT');
res.setHeader('Access-Control-Allow-Headers', 'X-Custom-Auth, Content-Type');
res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours
res.status(204).send();
} else {
res.status(403).send();
}
});
app.delete('/resource', (req, res) => {
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
res.json({ success: true });
});
π€ Did you know? The Access-Control-Max-Age header tells the browser how long to cache the preflight response. Setting this to a reasonable value (like 24 hours) reduces unnecessary OPTIONS requests and improves performance.
Decoding Browser Console Error Messages
Browser console error messages are your first line of defense when debugging CORS issues, but they can be cryptic. Learning to interpret these messages effectively saves hours of debugging time.
Here are the most common error messages and what they actually mean:
Error Message 1:
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.
β Wrong thinking: "I need to add CORS headers to my client-side code."
β
Correct thinking: "The server isn't sending the required Access-Control-Allow-Origin header in its response. I need to configure the server."
This is the most basic CORS error. It means your server didn't include any CORS headers at all in its response.
Error Message 2:
Access to fetch at 'https://api.example.com/data' from origin
'https://app.example.com' has been blocked by CORS policy:
The value of the 'Access-Control-Allow-Origin' header in the
response must not be the wildcard '*' when the request's
credentials mode is 'include'.
This is the wildcard-with-credentials problem we discussed earlier. The solution is to return the specific origin instead of *.
Error Message 3:
Access to fetch at 'https://api.example.com/data' from origin
'https://app.example.com' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
It does not have HTTP ok status.
This means the preflight OPTIONS request received a non-2xx status code (like 404, 500, or 403). Check that your server is configured to respond to OPTIONS requests with status 200 or 204.
Error Message 4:
Access to fetch at 'https://api.example.com/data' from origin
'https://app.example.com' has been blocked by CORS policy:
Request header field x-custom-header is not allowed by
Access-Control-Allow-Headers in preflight response.
Your server's preflight response is missing the custom header you're trying to send. Add it to the Access-Control-Allow-Headers list.
π‘ Mental Model: Think of CORS error messages as a conversation between the browser and server where the browser is explaining exactly which rule got violated. The error messages almost always point to missing or incorrect server-side headers.
The Client-Side CORS Headers Misconception
One of the most persistent misunderstandings about CORS is where the headers need to be set. This misconception wastes countless hours as developers add CORS headers to their client-side code, wondering why nothing changes.
β οΈ Common Mistake 3: Adding CORS headers to client-side requests β οΈ
// THIS DOESN'T WORK!
fetch('https://api.example.com/data', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*', // β Browsers ignore this!
'Access-Control-Allow-Methods': 'GET, POST' // β This too!
}
});
π― Key Principle: CORS is a server-to-browser communication mechanism. The browser looks for CORS headers in the server's response, not in the client's request.
Here's the correct mental model:
Client Request Server Response
(Client can't set (Server MUST set
CORS headers here) CORS headers here)
| |
v v
ββββββββββββββββ ββββββββββββββββββββββββ
β GET /api β β 200 OK β
β Origin: ... β ------β β Access-Control- β
β β β Allow-Origin: ... β
ββββββββββββββββ ββββββββββββββββββββββββ
When debugging, never add CORS headers to your fetch/axios requests. They have no effect and will only confuse your debugging process. Focus all your CORS configuration efforts on the server side.
π‘ Remember: The only headers you set in client-side requests are your business logic headers (authentication tokens, content types, custom data). CORS headers are exclusively server-response headers.
Practical Debugging Techniques
When faced with a CORS error, having a systematic debugging approach saves time and reduces frustration. Here are battle-tested techniques for diagnosing CORS issues.
Technique 1: Browser DevTools Network Tab Analysis
The Network tab in browser DevTools is your primary debugging tool. Here's how to use it effectively:
Step-by-step debugging process:
π§ Open DevTools (F12 or right-click β Inspect)
π§ Navigate to the Network tab and ensure it's recording
π§ Reproduce the failing request and look for the request in the list
π§ Check for a preflight OPTIONS request appearing before your actual request
π§ Click on the failed request and examine the Headers section
In the Headers section, look at two subsections:
Response Headers (what the server sent back):
- Is
Access-Control-Allow-Originpresent? - Does it match your origin exactly?
- Is
Access-Control-Allow-Credentialsset if you're sending credentials? - Are the
Access-Control-Allow-MethodsandAccess-Control-Allow-Headerspresent for preflights?
Request Headers (what the browser sent):
- What is the
Originheader value? - For OPTIONS requests, check
Access-Control-Request-MethodandAccess-Control-Request-Headers
π Quick Reference Card: Network Tab Debugging Checklist
| π Check | β Good | β Problem |
|---|---|---|
| π― Status code | 200/204 for preflight | 404, 403, 500 |
| π Allow-Origin header | Present, matches origin | Missing or mismatched |
| π« Credentials + Origin | Specific origin | Wildcard (*) |
| π Allow-Headers | Includes custom headers | Missing required headers |
| β‘ Allow-Methods | Includes request method | Missing request method |
Technique 2: Testing with cURL
Sometimes you need to isolate whether the problem is CORS-related or a deeper server issue. cURL lets you bypass browser CORS enforcement entirely.
## Test if the endpoint works at all (no CORS involved)
curl -X GET https://api.example.com/data
## Simulate a browser preflight request
curl -X OPTIONS https://api.example.com/data \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: DELETE" \
-H "Access-Control-Request-Headers: X-Custom-Auth" \
-i # Include response headers in output
## Test the actual request with origin header
curl -X DELETE https://api.example.com/data \
-H "Origin: https://app.example.com" \
-H "X-Custom-Auth: token123" \
-i
π‘ Pro Tip: If cURL succeeds but the browser fails, you know it's definitely a CORS configuration issue, not a problem with the endpoint itself.
Look for these patterns in cURL output:
Good response:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: X-Custom-Auth, Content-Type
Access-Control-Max-Age: 86400
Problem response:
HTTP/1.1 404 Not Found
(No CORS headers present)
Technique 3: OPTIONS Request Inspection
For complex CORS issues involving custom headers or non-standard methods, inspecting the OPTIONS preflight request in detail reveals exactly what the browser is asking for.
In the Network tab:
- Find the OPTIONS request (it appears before your main request)
- Check if it completed successfully (status 200 or 204)
- Examine the Request Headers to see what the browser is requesting permission for:
Access-Control-Request-Method: Which HTTP method needs permissionAccess-Control-Request-Headers: Which headers need permission
- Examine the Response Headers to see what the server granted:
Access-Control-Allow-Methods: Which methods are permittedAccess-Control-Allow-Headers: Which headers are permitted
The request and response should match. If the browser requests permission for a header and the server's response doesn't include it in Access-Control-Allow-Headers, the actual request will be blocked.
Advanced Debugging Scenarios
Some CORS issues are more subtle and require deeper investigation. Here are advanced scenarios you might encounter.
Scenario 1: Headers Present But Still Failing
Sometimes you see CORS headers in the response, but the browser still blocks the request. This often happens with header value mismatches.
Server sends: Access-Control-Allow-Origin: https://app.example.com
Browser origin: https://app.example.com/ (note the trailing slash)
Result: β BLOCKED (exact match required)
Origins must match exactly, including protocol, domain, and port. Even a trailing slash matters in the comparison.
π§ Mnemonic: E.P.D.P. - Exact Protocol, Domain, Port - all must match perfectly.
Scenario 2: Working in Development, Failing in Production
A common frustration is CORS working perfectly on localhost but failing in production. This usually stems from hardcoded origins:
// Development configuration that breaks in production
res.setHeader('Access-Control-Allow-Origin', 'http://localhost:3000');
When deployed, your frontend origin changes to https://app.example.com, but the server still sends localhost in the header.
β Solution: Use environment variables:
const allowedOrigins = process.env.ALLOWED_ORIGINS.split(',');
// ALLOWED_ORIGINS="http://localhost:3000,https://app.example.com,https://staging.app.example.com"
app.use((req, res, next) => {
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
next();
});
Scenario 3: Intermittent CORS Failures
If CORS works sometimes but fails other times, suspect caching issues or load balancer misconfigurations.
Some servers cache responses without considering the Origin header, causing the wrong Access-Control-Allow-Origin value to be returned to subsequent requests from different origins.
π‘ Pro Tip: Ensure your server includes the Vary: Origin header. This tells caches that the response varies based on the Origin header:
res.setHeader('Vary', 'Origin');
res.setHeader('Access-Control-Allow-Origin', origin);
This ensures that cached responses are keyed by origin, preventing one origin from receiving another origin's CORS headers.
Scenario 4: Proxy Configuration Complications
During development, many developers use proxy configurations to avoid CORS altogether:
// package.json (Create React App)
{
"proxy": "http://localhost:5000"
}
While this works in development (requests to /api get proxied to the backend), it doesn't help in production. The proxy only exists in your development server.
β οΈ Common Mistake 4: Relying on development proxies and forgetting to implement proper CORS for production β οΈ
Always test with actual cross-origin requests before deploying, or use a tool like http-server to serve your frontend on a different port during development.
Debugging Workflow Diagram
Here's a systematic workflow for debugging any CORS issue:
βββββββββββββββββββββββββββββββ
β CORS Error Encountered β
ββββββββββββ¬βββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββ
β Open DevTools Network Tab β
β Locate failed request β
ββββββββββββ¬βββββββββββββββββββ
β
βΌ
ββββββββ΄βββββββ
β Is there β
β an OPTIONS β YES
β request? ββββββββββββ
ββββββββ¬βββββββ β
β NO βΌ
β βββββββββββββββββββββββ
β β Check OPTIONS β
β β status code β
β ββββββββββ¬βββββββββββββ
β β
β ββββββββββ΄βββββββββ
β β Is it 200/204? β NO β Fix route handler
β ββββββββββ¬βββββββββ
β β YES
β βΌ
β βββββββββββββββββββββββ
β β Check Allow-Methods β
β β and Allow-Headers β
β β in response β
β ββββββββββ¬βββββββββββββ
β β
βΌ βΌ
ββββββββββββββββββββββββββββββββββββββββ
β Check actual request response β
β for Access-Control-Allow-Origin β
ββββββββββββ¬ββββββββββββββββββββββββββββ
β
βΌ
ββββββββ΄βββββββ
β Is header β NO β Add CORS header
β present? β to server
ββββββββ¬βββββββ
β YES
βΌ
ββββββββββββββββ
β Does origin β NO β Update allowed
β match exactlyβ origins list
ββββββββ¬ββββββββ
β YES
βΌ
ββββββββββββββββββββ
β Using credentialsβ YES β Check for wildcard
β (cookies)? β (must use specific origin)
ββββββββ¬ββββββββββββ
β NO
βΌ
ββββββββββββββββββββ
β Check for cache β
β issues (Vary) β
ββββββββββββββββββββ
Testing Strategies for CORS
Beyond reactive debugging, proactive testing helps catch CORS issues before they reach production.
π§ Strategy 1: Multi-Origin Testing
Test your API from multiple origins during development:
## Test from allowed origin
curl -X GET https://api.example.com/data \
-H "Origin: https://app.example.com" -i
## Test from disallowed origin (should be rejected)
curl -X GET https://api.example.com/data \
-H "Origin: https://malicious.com" -i
π§ Strategy 2: Automated CORS Testing
Include CORS checks in your integration tests:
// Jest/Supertest example
const request = require('supertest');
const app = require('../app');
test('OPTIONS request returns correct CORS headers', async () => {
const response = await request(app)
.options('/api/resource')
.set('Origin', 'https://app.example.com')
.set('Access-Control-Request-Method', 'DELETE');
expect(response.status).toBe(204);
expect(response.headers['access-control-allow-origin'])
.toBe('https://app.example.com');
expect(response.headers['access-control-allow-methods'])
.toContain('DELETE');
});
test('Rejects requests from unauthorized origins', async () => {
const response = await request(app)
.get('/api/resource')
.set('Origin', 'https://evil.com');
expect(response.headers['access-control-allow-origin'])
.toBeUndefined();
});
π§ Strategy 3: Browser Testing Across Environments
Don't rely solely on development environment testing. Use tools like BrowserStack or Sauce Labs to test actual cross-origin requests in real browsers before deploying to production.
Common Configuration Mistakes by Framework
Different frameworks have different CORS configuration patterns, and each has its common pitfalls.
Express.js:
- β Using
cors()middleware without configuringoriginoption (allows all origins in production) - β
Configure with specific origins:
cors({ origin: ['https://app.example.com'], credentials: true })
Django:
- β Adding URLs to
CORS_ALLOWED_ORIGINSwith trailing slashes inconsistently - β
Be consistent:
CORS_ALLOWED_ORIGINS = ['https://app.example.com'](no trailing slash)
Spring Boot:
- β Using
@CrossOriginwithout specifyingallowCredentials = "true"when needed - β
Explicitly configure:
@CrossOrigin(origins = "https://app.example.com", allowCredentials = "true")
.NET Core:
- β Forgetting to call
app.UseCors()in the correct middleware order (must be beforeUseAuthorization) - β Place CORS middleware early in the pipeline
π‘ Real-World Example: A developer spent hours debugging why their authenticated API calls worked in Postman but failed in the browser. The issue? They had configured Access-Control-Allow-Origin: * with credentials. When they changed to returning the specific origin, everything worked. This is one of the top three most common CORS mistakes.
When CORS Isn't the Real Problem
Not every cross-origin error is actually a CORS problem. Sometimes other issues masquerade as CORS errors.
Authentication/Authorization Failures:
If your server returns a 401 or 403 without CORS headers, browsers report it as a CORS error. The actual problem is authentication, but the missing headers make it look like CORS.
Network Errors:
If the server is down or unreachable, browsers sometimes report this as a CORS error because they never received a response with CORS headers.
Mixed Content Blocking:
Trying to make requests from an HTTPS page to an HTTP API triggers mixed content blocking, which manifests as a CORS-like error.
π― Key Principle: Always verify your endpoint is reachable and returns the expected status code using cURL or Postman before deep-diving into CORS configuration.
Final Debugging Checklist
Before you declare CORS victory, run through this final checklist:
β Server responds to OPTIONS requests with 200/204 status
β
Access-Control-Allow-Origin header is present and matches the requesting origin exactly
β If using credentials, origin is specific (not wildcard)
β
Access-Control-Allow-Credentials: true is present when sending credentials
β
Access-Control-Allow-Methods includes the actual request method
β
Access-Control-Allow-Headers includes all custom headers you're sending
β
Vary: Origin header is present to prevent cache issues
β Configuration works in both development and production environments
β Testing covers both allowed and disallowed origins
β No CORS headers are being set in client-side code
By following these debugging strategies and avoiding the common pitfalls outlined in this section, you'll be able to diagnose and resolve CORS issues quickly and confidently. Remember that CORS is fundamentally a server-side configuration concern, and most issues stem from missing or misconfigured response headers rather than problems with client-side code.
CORS Best Practices and Security Considerations
As you've learned throughout this lesson, CORS is a powerful mechanism that enables controlled cross-origin communication in web applications. However, with great power comes great responsibility. Security-conscious CORS configuration is essential to prevent vulnerabilities while maintaining the flexibility your application needs. In this final section, we'll explore best practices that will help you implement CORS safely in production environments.
The Principle of Least Privilege in CORS
π― Key Principle: Apply the principle of least privilege to your CORS configuration by allowing only the specific origins, methods, and headers that your application genuinely requires.
The principle of least privilege is a fundamental security concept that states systems should grant only the minimum level of access necessary to accomplish required tasks. When applied to CORS, this means being intentional and restrictive about what you permit in your configuration.
Origins: Instead of allowing all origins with a wildcard (*), explicitly whitelist only the domains that legitimately need access to your API. For example, if your frontend is hosted at https://app.example.com, that's the only origin you should permitβnot *.example.com or all origins.
Methods: Only allow the HTTP methods your API actually supports. If your endpoint only handles GET and POST requests, don't include PUT, DELETE, or PATCH in your Access-Control-Allow-Methods header. This reduces the attack surface by preventing unexpected request types.
Headers: Similarly, restrict Access-Control-Allow-Headers to only those headers your application genuinely needs. If you only require Content-Type and a custom X-API-Key header, don't allow arbitrary headers with a wildcard or an unnecessarily broad list.
Here's what a security-conscious CORS configuration looks like in practice:
// β Overly permissive - BAD
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', '*');
res.header('Access-Control-Allow-Headers', '*');
res.header('Access-Control-Allow-Credentials', 'true');
next();
});
// β
Principle of least privilege - GOOD
const ALLOWED_ORIGINS = ['https://app.example.com', 'https://mobile.example.com'];
const ALLOWED_METHODS = ['GET', 'POST'];
const ALLOWED_HEADERS = ['Content-Type', 'X-API-Key'];
app.use((req, res, next) => {
const origin = req.headers.origin;
// Only allow whitelisted origins
if (ALLOWED_ORIGINS.includes(origin)) {
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Credentials', 'true');
}
// Only allow necessary methods
res.header('Access-Control-Allow-Methods', ALLOWED_METHODS.join(', '));
// Only allow required headers
res.header('Access-Control-Allow-Headers', ALLOWED_HEADERS.join(', '));
// Handle preflight
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
π‘ Pro Tip: Maintain your allowed origins list in environment variables or a configuration file rather than hardcoding them. This makes it easier to manage different environments (development, staging, production) and update permissions without code changes.
The Dangers of Wildcard Abuse
One of the most critical security mistakes in CORS configuration is the abuse of wildcards, particularly the Access-Control-Allow-Origin: * pattern. While this might seem convenient during development, it creates serious security vulnerabilities in production.
β οΈ Common Mistake: Using Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true simultaneously. This combination is actually forbidden by the CORS specificationβbrowsers will reject it. However, developers sometimes try to work around this by dynamically reflecting the request origin, which can be even more dangerous. Mistake 1: Wildcard with credentials β οΈ
Why wildcards are dangerous:
π Credential leakage: If you allow all origins and include credentials, any malicious website can make authenticated requests to your API and access user data.
π Data theft: Public APIs might seem safe to expose via wildcard, but even non-credentialed requests can leak sensitive information if your API returns user-specific or business-critical data.
π CSRF vulnerabilities: Overly permissive CORS can effectively bypass CSRF protections, allowing attackers to perform state-changing operations on behalf of users.
π Attack surface expansion: By allowing all origins, you're trusting every website on the internet not to abuse your API, which is an untenable security posture.
The "dynamic origin reflection" anti-pattern:
Some developers, knowing they can't use wildcards with credentials, implement a "solution" that's actually worse:
// β οΈ EXTREMELY DANGEROUS - DO NOT USE
app.use((req, res, next) => {
// Blindly reflecting the origin header
res.header('Access-Control-Allow-Origin', req.headers.origin);
res.header('Access-Control-Allow-Credentials', 'true');
next();
});
This code blindly trusts whatever origin the browser sends, effectively creating a wildcard that works with credentials. Any malicious site can now make credentialed requests to your API. This is a critical security vulnerability.
π‘ Real-World Example: In 2018, researchers found that numerous popular APIs were vulnerable to this exact pattern, allowing attackers to steal user data by simply hosting a malicious page that made cross-origin requests with the victim's credentials.
Validating and Whitelisting Origins
The secure approach to handling multiple allowed origins is to maintain an explicit whitelist and validate incoming origin headers against it. Let's explore robust patterns for implementing this correctly.
## Python/Flask example with proper origin validation
from flask import Flask, request, jsonify
from flask_cors import CORS
import re
app = Flask(__name__)
## Method 1: Exact match whitelist (most secure)
ALLOWED_ORIGINS = {
'https://app.example.com',
'https://mobile.example.com',
'https://admin.example.com'
}
## Method 2: Pattern-based whitelist (use carefully)
ALLOWED_ORIGIN_PATTERNS = [
re.compile(r'https://[a-z]+\.example\.com$'), # Subdomains of example.com
re.compile(r'https://localhost:[0-9]+$') # Local development
]
def is_origin_allowed(origin):
"""Validate origin against whitelist"""
if not origin:
return False
# Check exact matches first
if origin in ALLOWED_ORIGINS:
return True
# Check pattern matches
for pattern in ALLOWED_ORIGIN_PATTERNS:
if pattern.match(origin):
return True
return False
@app.after_request
def apply_cors(response):
origin = request.headers.get('Origin')
if is_origin_allowed(origin):
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE'
response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
response.headers['Access-Control-Max-Age'] = '3600'
return response
@app.route('/api/data', methods=['GET', 'OPTIONS'])
def get_data():
if request.method == 'OPTIONS':
return '', 204
return jsonify({'message': 'Secure data'})
Key validation principles:
π§ Never trust the origin header blindly: Always validate it against a known whitelist before reflecting it back.
π§ Be careful with regex patterns: If you use pattern matching for subdomains, ensure your regex is precise and can't be bypassed. For example, .*\.example\.com is dangerous because it could match malicious.com.example.com.attacker.com.
π§ Use exact matching when possible: Explicit lists are more secure than pattern matching because they eliminate the risk of regex bypasses.
π§ Validate the protocol: Ensure you're checking for https:// and not accepting http:// in production unless absolutely necessary.
π§ Log rejected origins: Monitor attempts to access your API from non-whitelisted originsβunusual patterns might indicate an attack or misconfiguration.
π‘ Mental Model: Think of origin validation like a nightclub bouncer checking IDs against a VIP list. The bouncer doesn't just let anyone in who shows up; they verify the name against an approved list. Similarly, your server should check each origin against an explicit whitelist before granting access.
CORS vs Alternatives: Making the Right Choice
While CORS is the modern standard for cross-origin communication, it's not always the right solution. Understanding when to use CORS versus alternatives will help you make architectural decisions that balance functionality, security, and maintainability.
JSONP (Deprecated - Avoid)
JSONP (JSON with Padding) was a pre-CORS technique for cross-origin requests that exploited the fact that <script> tags aren't subject to same-origin policy. While you might encounter it in legacy codebases, JSONP should be considered deprecated and avoided in new projects.
β Wrong thinking: "JSONP is simpler than CORS, so I'll use it for my API." β Correct thinking: "JSONP has significant security limitations and no support for modern features. CORS is the standard approach."
Why JSONP is problematic:
- π« Security vulnerabilities: JSONP inherently trusts the server to execute arbitrary JavaScript, creating XSS risks
- π« No credential control: Can't properly manage authentication like CORS credentials
- π« GET-only: Limited to GET requests, no support for POST, PUT, DELETE, etc.
- π« Poor error handling: Difficult to detect and handle failures
- π« No modern browser features: Can't use features like custom headers or preflight requests
When you might encounter JSONP: Only in legacy applications that need to support very old browsers (IE9 and earlier). Even then, consider whether a server-side proxy might be a better solution.
Server-Side Proxies
A server-side proxy involves routing requests through your own backend server, which then makes requests to third-party APIs. Since server-to-server communication isn't subject to same-origin policy, this bypasses CORS entirely.
// Frontend makes request to your server (same-origin)
fetch('/api/proxy/external-data')
.then(response => response.json())
.then(data => console.log(data));
// Your server proxies to external API
// Node.js/Express example
const express = require('express');
const axios = require('axios');
const app = express();
app.get('/api/proxy/external-data', async (req, res) => {
try {
// Server makes request to third-party API
const response = await axios.get('https://external-api.com/data', {
headers: {
'Authorization': `Bearer ${process.env.EXTERNAL_API_KEY}`
}
});
// Return data to client
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch data' });
}
});
When to use a server-side proxy instead of CORS:
β API keys should stay secret: If the third-party API requires authentication keys that shouldn't be exposed to browsers, a proxy keeps them server-side.
β Rate limiting and caching: Your proxy can implement caching, rate limiting, or request aggregation that wouldn't be possible with direct client requests.
β Legacy API compatibility: When the third-party API doesn't support CORS, a proxy is your only option besides JSONP.
β Request transformation: If you need to transform, filter, or aggregate data before sending it to the client, a proxy provides a natural layer for this logic.
β Enhanced security: Proxying allows you to validate, sanitize, and log all requests, adding a security layer.
When CORS is the better choice:
β You control the API: If it's your own API on a different domain, properly configured CORS is simpler and more efficient.
β Public APIs with CORS support: Many modern APIs (Google Maps, GitHub, etc.) support CORS for browser requestsβuse their direct support rather than adding proxy complexity.
β Reducing server load: Direct browser-to-API communication with CORS eliminates your server as a middleman, reducing latency and server costs.
β Real-time communication: WebSocket connections and other real-time protocols work better with direct CORS-enabled connections.
Decision Matrix
π Quick Reference Card: CORS vs Alternatives
| Scenario π― | Recommended Solution π‘ | Rationale π§ |
|---|---|---|
| Your own API on different subdomain | CORS with strict whitelist | Most efficient, full control over configuration |
| Third-party public API with CORS | Direct CORS request | Use provider's CORS support, no proxy needed |
| Third-party API without CORS | Server-side proxy | Only option besides deprecated JSONP |
| API keys must stay secret | Server-side proxy | Keep credentials server-side, never expose to browser |
| Need request caching/aggregation | Server-side proxy | Add server logic layer for optimization |
| Supporting IE9 and earlier | Server-side proxy preferred | Avoid JSONP security issues, proxy is cleaner |
| Real-time WebSocket communication | CORS | Direct connection reduces latency |
| Need to log/monitor all API calls | Server-side proxy | Centralized logging and security monitoring |
Configuration Decision Matrix
Choosing the right CORS configuration depends on your specific use case. Here's a comprehensive decision matrix to guide your implementation:
Use Case 1: Public Read-Only API
Your API provides public data (e.g., weather information, public statistics) with no authentication required.
// Configuration for public read-only API
res.header('Access-Control-Allow-Origin', '*'); // Acceptable here
res.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
// No credentials header - not needed for public data
β Wildcard is acceptable because:
- No sensitive data is exposed
- No credentials are involved
- Read-only access (GET only)
- Rate limiting handles abuse
β οΈ Still consider: Even public APIs might benefit from origin whitelisting if you want to control who uses your service or implement per-origin rate limits.
Use Case 2: Authenticated Single-Page Application
Your frontend SPA on app.example.com communicates with an API on api.example.com using session cookies or tokens.
// Configuration for authenticated SPA
const ALLOWED_ORIGINS = [
'https://app.example.com',
'https://staging.example.com',
process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null
].filter(Boolean);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.includes(origin)) {
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-CSRF-Token');
res.header('Access-Control-Max-Age', '86400'); // 24 hours
}
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
β This configuration:
- Whitelists specific origins only
- Enables credentials for authentication
- Supports all CRUD operations
- Includes CSRF token header
- Caches preflight for 24 hours
Use Case 3: Mobile App Backend
Your API serves a mobile app that makes requests from WebView components or hybrid frameworks.
π€ Did you know? Mobile apps using WebViews often send null or file:// origins, which requires special handling in CORS configuration.
// Configuration for mobile WebView
const ALLOWED_ORIGINS = [
'https://app.example.com',
'file://', // Mobile app local files
'capacitor://localhost', // Capacitor apps
'ionic://localhost' // Ionic apps
];
app.use((req, res, next) => {
const origin = req.headers.origin || 'null';
// Special handling for mobile apps
if (ALLOWED_ORIGINS.includes(origin) ||
(origin === 'null' && isMobileAppRequest(req))) {
res.header('Access-Control-Allow-Origin', origin === 'null' ? '*' : origin);
res.header('Access-Control-Allow-Credentials', origin !== 'null' ? 'true' : 'false');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-App-Version');
}
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
function isMobileAppRequest(req) {
// Verify request is from your mobile app using custom headers or tokens
const appVersion = req.headers['x-app-version'];
const appToken = req.headers['x-app-token'];
return appVersion && appToken === process.env.MOBILE_APP_SECRET;
}
Use Case 4: Microservices Architecture
Your frontend communicates with multiple backend services, each on different subdomains.
// Shared CORS middleware for microservices
const ALLOWED_ORIGIN_PATTERN = /^https:\/\/[a-zA-Z0-9-]+\.example\.com$/;
const ALLOWED_DEV_ORIGINS = ['http://localhost:3000', 'http://localhost:8080'];
function isOriginAllowed(origin) {
if (!origin) return false;
// Development origins
if (process.env.NODE_ENV === 'development' && ALLOWED_DEV_ORIGINS.includes(origin)) {
return true;
}
// Production pattern match
return ALLOWED_ORIGIN_PATTERN.test(origin);
}
app.use((req, res, next) => {
const origin = req.headers.origin;
if (isOriginAllowed(origin)) {
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Service-Token');
res.header('Access-Control-Expose-Headers', 'X-Total-Count, X-Page-Number');
}
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
Additional Security Hardening
Beyond basic CORS configuration, consider these additional security measures to further protect your API:
1. Combine CORS with Other Security Headers
CORS works best as part of a defense-in-depth strategy alongside other security headers:
app.use((req, res, next) => {
// CORS headers (as configured above)
// ...
// Additional security headers
res.header('X-Content-Type-Options', 'nosniff');
res.header('X-Frame-Options', 'DENY');
res.header('X-XSS-Protection', '1; mode=block');
res.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
// CSP to restrict script sources
res.header('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'");
next();
});
2. Implement Rate Limiting per Origin
π Rate limiting by origin prevents abuse even from whitelisted domains:
const rateLimit = require('express-rate-limit');
const createOriginLimiter = (origin) => rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: origin.includes('localhost') ? 1000 : 100, // Different limits for dev vs prod
keyGenerator: (req) => req.headers.origin || 'unknown'
});
3. Monitor and Log CORS Violations
Keep track of rejected CORS requests to identify potential attacks or misconfigurations:
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && !isOriginAllowed(origin)) {
// Log the violation
console.warn('CORS violation attempt:', {
origin,
path: req.path,
method: req.method,
ip: req.ip,
timestamp: new Date().toISOString()
});
// Optionally alert security team for repeated violations
// checkForRepeatedViolations(req.ip, origin);
}
next();
});
4. Use HTTPS Everywhere
β οΈ Critical: CORS configurations should always require HTTPS in production. Mixed content (HTTPS site requesting HTTP resources) creates vulnerabilities that CORS can't protect against.
// Enforce HTTPS origins in production
function isOriginAllowed(origin) {
if (!origin) return false;
// Reject HTTP origins in production
if (process.env.NODE_ENV === 'production' && origin.startsWith('http://')) {
return false;
}
// ... rest of validation
}
Environment-Specific Configurations
Different environments require different CORS configurations. Here's a pattern for managing this cleanly:
// config/cors.js
const CORS_CONFIG = {
development: {
origins: [
'http://localhost:3000',
'http://localhost:8080',
'http://127.0.0.1:3000'
],
credentials: true,
maxAge: 600 // 10 minutes - shorter for faster dev iteration
},
staging: {
origins: [
'https://staging.example.com',
'https://staging-mobile.example.com'
],
credentials: true,
maxAge: 3600 // 1 hour
},
production: {
origins: [
'https://app.example.com',
'https://mobile.example.com'
],
credentials: true,
maxAge: 86400 // 24 hours - longer for production performance
}
};
module.exports = CORS_CONFIG[process.env.NODE_ENV || 'development'];
π‘ Pro Tip: Use environment variables for origin configuration rather than hardcoding. This allows you to add temporary origins for testing without deploying code changes: ALLOWED_ORIGINS=https://app.example.com,https://test.example.com
Testing Your CORS Configuration
Before deploying to production, thoroughly test your CORS configuration:
1. Test preflight requests manually:
## Test OPTIONS preflight
curl -X OPTIONS https://api.example.com/endpoint \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type" \
-v
## Verify response includes:
## Access-Control-Allow-Origin: https://app.example.com
## Access-Control-Allow-Methods: POST
## Access-Control-Allow-Headers: Content-Type
2. Test actual requests:
curl -X POST https://api.example.com/endpoint \
-H "Origin: https://app.example.com" \
-H "Content-Type: application/json" \
-d '{"test": "data"}' \
-v
3. Test rejection of unauthorized origins:
curl -X GET https://api.example.com/endpoint \
-H "Origin: https://malicious.com" \
-v
## Should NOT include Access-Control-Allow-Origin in response
4. Automated testing:
Include CORS tests in your integration test suite:
// Jest/Supertest example
test('allows whitelisted origin with credentials', async () => {
const response = await request(app)
.get('/api/data')
.set('Origin', 'https://app.example.com');
expect(response.headers['access-control-allow-origin']).toBe('https://app.example.com');
expect(response.headers['access-control-allow-credentials']).toBe('true');
});
test('rejects non-whitelisted origin', async () => {
const response = await request(app)
.get('/api/data')
.set('Origin', 'https://evil.com');
expect(response.headers['access-control-allow-origin']).toBeUndefined();
});
SUMMARY
You've now completed a comprehensive journey through CORS, from understanding the same-origin policy to implementing production-ready, security-conscious configurations. Let's recap what you've learned:
What You Now Understand
Before this lesson, CORS might have seemed like a mysterious browser restriction that you fought against with StackOverflow copy-paste solutions.
Now you understand:
π§ Why CORS exists: The same-origin policy protects users, and CORS is the controlled mechanism to safely relax these restrictions when needed.
π§ How CORS works: The complete request-response flow, including preflight requests, CORS headers, and browser enforcement.
π§ How to implement CORS: Practical patterns for both client and server sides across various frameworks and scenarios.
π§ How to debug CORS: Systematic approaches to identifying and resolving the most common CORS errors.
π§ How to secure CORS: Applying the principle of least privilege, avoiding dangerous patterns, and implementing production-ready configurations.
Critical Points to Remember
β οΈ Never use Access-Control-Allow-Origin: * with credentials - This is forbidden by the specification and creates severe security vulnerabilities.
β οΈ Never blindly reflect the origin header - Always validate against an explicit whitelist before echoing the origin back.
β οΈ Always use HTTPS in production - HTTP origins in production environments create security vulnerabilities that CORS cannot protect against.
β οΈ Test your CORS configuration thoroughly - What works in development might fail in production due to environment differences.
CORS Configuration Decision Table
π Quick Reference Card: CORS Security Checklist
| Configuration Aspect π§ | Insecure β | Secure β |
|---|---|---|
| Origin handling | Access-Control-Allow-Origin: * with credentials |
Explicit whitelist validation |
| Origin validation | Reflecting origin without checking | Validate against known list |
| Methods allowed | Access-Control-Allow-Methods: * |
Only necessary methods (GET, POST, etc.) |
| Headers allowed | Access-Control-Allow-Headers: * |
Specific required headers only |
| Protocol | Allowing HTTP in production | HTTPS-only origins |
| Credentials | Enabled when not needed | Only when authentication required |
| Preflight caching | Not set or very short | Appropriate max-age (3600-86400) |
| Error handling | Generic error messages | Specific, logged errors |
| Testing | Only browser testing | Automated tests + manual verification |
| Monitoring | No logging of violations | Log and alert on repeated violations |
Practical Applications and Next Steps
Now that you understand CORS thoroughly, here are practical next steps to apply your knowledge:
1. Audit Your Existing Applications
π§ Review your current CORS configurations against the security checklist above. Look for:
- Wildcard usage that should be replaced with whitelists
- Missing origin validation
- Overly permissive method or header allowances
- HTTP origins in production
Schedule time to refactor insecure configurations before they become security incidents.
2. Implement a Centralized CORS Configuration
π§ Create a reusable CORS middleware or configuration module that:
- Manages allowed origins from environment variables
- Implements proper validation logic
- Logs CORS violations for security monitoring
- Can be shared across microservices
This prevents configuration drift and ensures consistent security across your infrastructure.
3. Expand Your Security Knowledge
π§ CORS is just one piece of web security. Continue learning about:
- Content Security Policy (CSP): Controls which resources browsers can load
- CSRF tokens: Additional protection for state-changing operations
- OAuth 2.0 and OpenID Connect: Modern authentication and authorization
- Security headers: Complete defense-in-depth header strategies
- API security best practices: Rate limiting, input validation, and more
Final Thought
π― Key Principle: Security is not a one-time configuration but an ongoing practice. As your application evolvesβadding new features, supporting new clients, or scaling to new environmentsβregularly revisit your CORS configuration to ensure it remains both functional and secure.
CORS is a powerful tool that enables modern web architectures while maintaining security boundaries. By understanding its mechanics and applying the best practices covered in this lesson, you can confidently build applications that are both flexible and secure. The key is always to start with the most restrictive configuration possible and only relax restrictions when you have a clear, security-reviewed reason to do so.
Congratulations on mastering CORS! You're now equipped to implement cross-origin communication patterns that balance functionality with security in production web applications.