Every web developer has seen this error:
Access to fetch at 'https://api.example.com' from origin 'http://localhost:3000'
has been blocked by CORS policy
CORS isnโt a bug. Itโs a security feature. But understanding why it exists and how it works makes it much less frustrating.
The Same-Origin Policy
Browsers enforce a rule: JavaScript on https://mysite.com can only make requests to https://mysite.com. Requests to any other origin (different domain, port, or protocol) are blocked by default.
This prevents a malicious site from making requests to your bankโs API using your cookies. Without this rule, any website could silently access your authenticated sessions on other sites.
What CORS Actually Is
CORS (Cross-Origin Resource Sharing) is the mechanism that lets servers opt in to receiving cross-origin requests. The server says โIโm okay with requests from these originsโ by setting response headers.
Access-Control-Allow-Origin: https://mysite.com
This header tells the browser: โRequests from mysite.com are allowed.โ Without it, the browser blocks the response.
Important: the server still receives and processes the request. CORS is enforced by the browser, not the server. The server sends the response, but the browser refuses to give it to your JavaScript.
Simple Requests vs Preflight
Not all cross-origin requests are treated the same.
Simple requests go through directly. A request is โsimpleโ if it uses GET, HEAD, or POST with standard headers and content types (text/plain, application/x-www-form-urlencoded, multipart/form-data).
Everything else triggers a preflight. If you send Content-Type: application/json, or use PUT/DELETE/PATCH, or add custom headers like Authorization, the browser sends an OPTIONS request first:
OPTIONS /api/users HTTP/1.1
Origin: https://mysite.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
The server must respond with what it allows:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://mysite.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Only if the preflight passes does the browser send the actual request.
Why Preflight Exists
Preflight protects servers that were built before CORS existed. Old servers assumed that browsers would never send a DELETE request or a request with Content-Type: application/json from a different origin. Preflight ensures these servers arenโt surprised by requests they never expected.
The Headers That Matter
# Which origins can access the resource
Access-Control-Allow-Origin: https://mysite.com
# Or allow any origin (careful with this):
Access-Control-Allow-Origin: *
# Which HTTP methods are allowed
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
# Which request headers are allowed
Access-Control-Allow-Headers: Content-Type, Authorization
# Should the browser send cookies?
Access-Control-Allow-Credentials: true
# How long to cache the preflight response (seconds)
Access-Control-Max-Age: 86400
The Credentials Trap
If your request includes cookies or an Authorization header, you need Access-Control-Allow-Credentials: true. But hereโs the catch: when credentials are included, Access-Control-Allow-Origin cannot be *. You must specify the exact origin.
# This won't work with credentials:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
# This works:
Access-Control-Allow-Origin: https://mysite.com
Access-Control-Allow-Credentials: true
This trips up almost everyone the first time.
Common Fixes
Development: Use a proxy. Your frontend dev server (Vite, Next.js) can proxy API requests so they appear same-origin:
// vite.config.js
export default {
server: {
proxy: {
'/api': 'http://localhost:8080'
}
}
}
Production: Set the headers on your API server. In Express:
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', 'https://mysite.com')
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
next()
})
Nginx: Add headers at the reverse proxy level:
location /api/ {
add_header Access-Control-Allow-Origin "https://mysite.com" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
if ($request_method = OPTIONS) {
return 204;
}
}
The Gotcha Nobody Mentions
CORS errors in the browser console donโt show the actual server error. If your server returns a 500 error without CORS headers, the browser shows a CORS error โ not the 500. Always check your server logs when debugging CORS issues.
Related: CORS Error: Blocked by CORS Policy โ fix ยท How HTTPS Keeps Your Data Safe ยท What is CORS? ยท How Ssh Actually Works