Understanding CORS: The Developer's Guide

Frontend and Tutorials

Understanding CORS: The Developer's Guide

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, or POST
  • Only standard headers (Accept, Content-Type, Content-Language)
  • Content-Type is application/x-www-form-urlencoded, multipart/form-data, or text/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:

  1. Same origin: Serve frontend and API from the same domain (no CORS needed)
  2. CORS headers: Configure your API to accept requests from your frontend's origin
  3. 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_ORIGIN as 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 Origin header. 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.