If you've ever opened your browser console and seen a red error message about CORS policy,
you're not alone. CORS errors are one of the most common and most frustrating issues in web development — and they almost always surface after deployment, not during local development.
This guide explains what CORS is, why browsers enforce it, and how to fix it in every major framework.
What Is CORS?
CORS stands for Cross-Origin Resource Sharing. It's a security mechanism built into web browsers that controls which websites can make requests to your server.
When your frontend at https://app.example.com tries to fetch data from https://api.example.com, the browser checks whether the API server explicitly allows requests from app.example.com. If the server doesn't say yes, the browser blocks the response.
This isn't your server rejecting the request — the server processes it normally. The browser blocks the response from reaching your JavaScript code.
The Same-Origin Policy
CORS is built on top of the Same-Origin Policy, which defines what counts as the same origin
:
https://app.example.com:443/page
↑ ↑ ↑
protocol domain port
Two URLs have the same origin only if all three parts match:
| URL A | URL B | Same origin? | Why |
|---|---|---|---|
https://app.example.com |
https://app.example.com/api |
Yes | Same protocol, domain, port |
https://app.example.com |
http://app.example.com |
No | Different protocol |
https://app.example.com |
https://api.example.com |
No | Different subdomain |
https://app.example.com |
https://app.example.com:8080 |
No | Different port |
When origins don't match, the browser requires CORS headers from the server before it will allow the response through.
Simple Requests vs Preflight Requests
Not all cross-origin requests are handled the same way.
Simple Requests
The browser sends the request directly if it meets all of these conditions:
- Method is
GET,HEAD, orPOST - Only standard headers (
Accept,Content-Type,Content-Language) Content-Typeisapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain
For simple requests, the browser sends the request and checks the CORS headers in the response.
Preflight Requests
For anything else — PUT, DELETE, PATCH, custom headers, Content-Type: application/json — the browser sends a preflight OPTIONS request first:
Browser → OPTIONS /api/users (preflight)
Server → 200 OK + CORS headers
Browser → PUT /api/users (actual request)
Server → 200 OK + data
sequenceDiagram
Browser->>Server: OPTIONS /api/users (preflight)
Server-->>Browser: 200 OK + CORS headers
Browser->>Server: PUT /api/users (actual request)
Server-->>Browser: 200 OK + response data
The preflight asks: Am I allowed to make this type of request?
If the server responds with the right headers, the browser proceeds with the actual request.
CORS Headers Explained
Access-Control-Allow-Origin
The most important header. It tells the browser which origins can access the response:
Access-Control-Allow-Origin: https://app.example.com
Or allow any origin (use with caution):
Access-Control-Allow-Origin: *
Important: You can only specify one origin or *. You cannot list multiple origins. To support multiple origins, your server must check the request's Origin header and echo it back if it's in your allowed list.
Access-Control-Allow-Methods
Which HTTP methods are permitted:
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH
Access-Control-Allow-Headers
Which request headers are permitted:
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With
Access-Control-Allow-Credentials
Whether the browser should send cookies and authentication:
Access-Control-Allow-Credentials: true
Critical: When this is true, Access-Control-Allow-Origin cannot be *. You must specify the exact origin.
Access-Control-Max-Age
How long (in seconds) the browser caches the preflight response:
Access-Control-Max-Age: 86400
This avoids sending a preflight OPTIONS request before every actual request. Set it to 24 hours (86400) for production.
Access-Control-Expose-Headers
Which response headers JavaScript can read (beyond the basic set):
Access-Control-Expose-Headers: X-Total-Count, X-Request-Id
How to Enable CORS
Node.js / Express
The cors middleware is the standard approach:
npm install cors
const express = require('express');
const cors = require('cors');
const app = express();
// Allow specific origin
app.use(cors({
origin: 'https://app.example.com',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400
}));
// Or allow multiple origins
app.use(cors({
origin: ['https://app.example.com', 'https://staging.example.com'],
credentials: true
}));
Python / Django
Install django-cors-headers:
pip install django-cors-headers
# settings.py
INSTALLED_APPS = [
'corsheaders',
# ...
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', # Must be before CommonMiddleware
'django.middleware.common.CommonMiddleware',
# ...
]
CORS_ALLOWED_ORIGINS = [
'https://app.example.com',
'https://staging.example.com',
]
CORS_ALLOW_CREDENTIALS = True
Python / Flask
pip install flask-cors
from flask import Flask
from flask_cors import CORS
app = Flask(__name__)
CORS(app, origins=['https://app.example.com'], supports_credentials=True)
Ruby on Rails
Add rack-cors to your Gemfile:
gem 'rack-cors'
# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'https://app.example.com'
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options],
credentials: true,
max_age: 86400
end
end
PHP (Manual Headers)
<?php
$allowed_origin = 'https://app.example.com';
if (isset($_SERVER['HTTP_ORIGIN']) && $_SERVER['HTTP_ORIGIN'] === $allowed_origin) {
header("Access-Control-Allow-Origin: $allowed_origin");
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE");
header("Access-Control-Allow-Headers: Content-Type, Authorization");
header("Access-Control-Allow-Credentials: true");
header("Access-Control-Max-Age: 86400");
}
// Handle preflight
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
Nginx (Server-Level)
server {
listen 80;
server_name api.example.com;
location / {
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://app.example.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE';
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
add_header 'Access-Control-Max-Age' 86400;
add_header 'Content-Length' 0;
return 204;
}
add_header 'Access-Control-Allow-Origin' 'https://app.example.com';
add_header 'Access-Control-Allow-Credentials' 'true';
proxy_pass http://127.0.0.1:3000;
}
}
Common CORS Errors and Fixes
No 'Access-Control-Allow-Origin' header is present
The server isn't sending CORS headers. Add the appropriate middleware or headers for your framework.
The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'
You're using credentials: true (or withCredentials: true in fetch/axios) but the server is responding with Access-Control-Allow-Origin: *. You must specify the exact origin:
// Wrong
res.header('Access-Control-Allow-Origin', '*');
// Correct
res.header('Access-Control-Allow-Origin', 'https://app.example.com');
Preflight Failures (OPTIONS Returns 405 or 404)
Your server doesn't handle OPTIONS requests. Some frameworks need explicit configuration:
// Express: cors middleware handles this automatically
// Without cors middleware, add:
app.options('*', (req, res) => {
res.header('Access-Control-Allow-Origin', 'https://app.example.com');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.sendStatus(204);
});
CORS Works Locally but Fails in Production
This is the most common deployment issue. Locally, your frontend and API often run on localhost (same origin or with a dev proxy). In production, they're on different subdomains.
Check that your production server's CORS configuration includes your production frontend URL, not just localhost:3000.
CORS in Development vs Production
Development
During development, CORS issues are often avoided with a dev proxy. Most frontend tools support this:
Vite:
// vite.config.js
export default {
server: {
proxy: {
'/api': 'http://localhost:3000'
}
}
}
The proxy makes API requests appear same-origin during development, bypassing CORS entirely.
Production
In production, you have several options:
- Same origin: Serve frontend and API from the same domain (no CORS needed)
- CORS headers: Configure your API to accept requests from your frontend's origin
- Reverse proxy: Use Nginx to serve both frontend and API from the same domain
Option 3 is often the simplest. If your frontend and API are both deployed via DeployHQ, an Nginx reverse proxy can route /api/* to your backend and everything else to your frontend — making them the same origin.
How This Relates to Deployment
CORS issues are deployment issues. They rarely appear during development (because of dev proxies or same-origin localhost) and almost always appear when code hits staging or production.
When you deploy with DeployHQ from GitHub or GitLab:
- Your CORS configuration deploys with your code — it's part of your application's middleware or server config
- Environment-specific origins can be managed via DeployHQ's build pipelines — set
CORS_ORIGINas a build variable that differs between staging and production - Nginx config deploys alongside your app — your reverse proxy rules are version-controlled and auto-deployed
For agencies deploying frontend and backend separately for clients, getting CORS right from the start saves hours of debugging post-deploy.
Security Considerations
- Never use
*in production for APIs that handle authentication. It allows any website to make requests to your API - Validate origins: Don't blindly echo the
Originheader. Maintain a whitelist of allowed origins - Don't confuse CORS with authentication. CORS controls which websites can talk to your API. It doesn't replace API authentication (tokens, sessions)
- Credentials require specific origins. If you use cookies or auth headers, you must specify exact origins —
*is not allowed
FAQ
Does CORS apply to server-to-server requests? No. CORS is a browser-only mechanism. Server-to-server HTTP requests (curl, fetch from Node.js, Python requests) are not subject to CORS restrictions.
Can I disable CORS in the browser?
Yes, for testing — but never in production. Chrome can be launched with --disable-web-security, but this is dangerous and only for debugging.
Does CORS protect my API from abuse? No. CORS is enforced by browsers, not servers. Anyone can use curl or Postman to make requests directly. Use authentication and rate limiting to protect your API.
Why does my API work in Postman but not in the browser? Postman doesn't enforce CORS — it's not a browser. The same request that fails in the browser due to CORS will succeed in Postman because there's no same-origin policy to enforce.
Should I handle CORS in my application or in Nginx? Either works. Application-level CORS (Express cors middleware, Django cors-headers) is more flexible and portable. Nginx-level CORS is useful when you can't modify the application. Don't configure CORS in both places — the headers can conflict.
CORS is confusing at first, but it's simple once you understand the flow: the browser asks the server for permission, the server responds with headers, and the browser enforces the result. Get your headers right, test on staging before production, and you'll never see that red console error again.
Try DeployHQ free — deploy your frontend and backend together with consistent CORS configuration. See pricing for team plans.
Questions? Reach out at support@deployhq.com or @deployhq.