Client Request CORS Headers Server 🔒
Backend

Understanding CORS and How to Handle It

Mayur Dabhi
Mayur Dabhi
March 6, 2026
20 min read

Your React app on localhost:5173 calls the API on localhost:8000. The same request works in Postman and with cURL, the server logs show a 200, and yet the browser console shows a red CORS error and your fetch rejects with a vague TypeError: Failed to fetch. Most people's first move is to paste Access-Control-Allow-Origin: * somewhere and hope.

This post explains what's actually going on: why the browser blocks the response, how simple and preflight requests differ, which headers matter, and how to configure CORS properly in Node.js, Express, Laravel and Nginx without opening a hole in your API.

What's Covered
  • What CORS is and why browsers enforce it
  • The difference between simple and preflight requests
  • The CORS headers you'll actually touch
  • How to configure CORS in Node.js, Express, Laravel, and Nginx
  • Common CORS errors and how to fix them
  • What to check before you ship a CORS config

What is CORS?

Browsers start from the Same-Origin Policy (SOP): JavaScript on one origin can't read responses from another origin. Cross-Origin Resource Sharing (CORS) is the way a server says "this other origin is allowed to read my responses." Without it, scripts can send cross-origin requests, but they don't get to see what comes back.

✓ Same Origin (Allowed) https://app.com /page https://app.com /api/data ✗ Cross Origin (Blocked by Default) https://app.com /page ✗ https://api.com /data What defines an "Origin"? Protocol https:// + Domain example.com + Port :443 = Origin https://example.com:443

Origins consist of protocol, domain, and port. Any difference means "cross-origin"

An origin is three things: the protocol (http/https), the domain (example.com) and the port (80, 443, 3000, etc.). Change any one of them and you're cross-origin. That's why localhost:3000 talking to localhost:8000 trips CORS even though it's the same machine.

Same Origin Examples

https://app.com/page1 → https://app.com/page2
https://app.com:443/a → https://app.com/b

Cross Origin Examples

https://app.com → http://app.com (protocol)
https://app.com → https://api.app.com (subdomain)

Why Does CORS Exist?

The restriction protects users, not servers. Your browser holds cookies for every site you're logged into. Without the Same-Origin Policy, any page you open could use them:

Attack Scenario Without SOP

Imagine you visit evil-site.com while logged into your bank. Without same-origin restrictions, evil-site.com's JavaScript could:

  • Fetch your account details from bank.com/api/balance
  • Initiate a transfer to the attacker's account
  • All using YOUR authenticated session cookies

CORS is a controlled hole in that wall. The server lists which origins may read its responses, and the browser enforces that list. Your SPA on app.com gets to talk to api.app.com, and a random site still doesn't.

How CORS Works

On a cross-origin request, the browser adds an Origin header, then looks at the response headers to decide whether your JavaScript gets to see the result. All of the server's policy lives in those headers.

Simple Requests vs. Preflight Requests

There are two kinds of request here. Simple requests go straight to the server. Anything else gets a preflight: the browser first sends an OPTIONS request asking permission, and only sends the real request if the answer is yes.

Simple Request Flow Browser Server 1. GET /api/data 2. Response + CORS headers Preflight Request Flow Browser Server 1. OPTIONS (preflight) 2. CORS headers 3. Actual POST request 4. Response Simple Request Conditions Methods allowed: GET, HEAD, POST Headers allowed: Accept, Accept-Language, Content-Language Content-Type (with restrictions) Content-Type values: application/x-www-form-urlencoded multipart/form-data, text/plain Preflight Required When Methods: PUT, DELETE, PATCH, etc. Custom headers: Authorization, X-Custom-Header, etc. Content-Type: application/json application/xml

Simple requests go directly to the server; preflight requests require an OPTIONS check first

Why Preflight Exists

Preflight protects servers written before CORS existed. An old API might happily run a DELETE from any page without ever expecting a cross-origin caller. The OPTIONS check means the server has to opt in before the browser sends anything that could change state. A practical side effect: sending JSON with Content-Type: application/json always triggers a preflight, so your API must answer OPTIONS correctly.

The CORS Headers You'll Actually Use

There aren't many of them. Most bugs come from one of these being missing or slightly wrong.

Response Headers (Server → Browser)

Header Description Example Value
Access-Control-Allow-Origin Specifies which origin(s) can access the resource https://app.com or *
Access-Control-Allow-Methods HTTP methods allowed for cross-origin requests GET, POST, PUT, DELETE
Access-Control-Allow-Headers Custom headers the client is allowed to send Content-Type, Authorization
Access-Control-Allow-Credentials Whether cookies/auth headers can be included true
Access-Control-Max-Age How long preflight results can be cached (seconds) 86400
Access-Control-Expose-Headers Response headers your JavaScript is allowed to read X-Request-Id, X-Rate-Limit

Request Headers (Browser → Server)

Header Description Example Value
Origin The origin making the request (sent automatically) https://app.com
Access-Control-Request-Method The HTTP method of the actual request (preflight only) DELETE
Access-Control-Request-Headers Custom headers to be sent (preflight only) Content-Type, Authorization

The Classic CORS Error

You've seen this one, probably in Chrome:

Access to fetch at 'https://api.example.com/data' from origin 'https://myapp.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

It means the response came back without an Access-Control-Allow-Origin header, or with one that didn't match the requesting origin. Your code only sees a generic network error; the detailed reason is only in the console.

CORS Is Enforced by the Browser

The server doesn't block anything. For simple requests, the request reaches the server and gets processed. The browser just refuses to hand the response to your JavaScript. So:

  • Server-to-server requests work fine (no browser = no CORS)
  • Postman, cURL, and other tools ignore CORS
  • The fix belongs on the server (or a proxy in front of it), never in frontend code

Implementing CORS

Pick the layer that owns your responses. If the app framework handles CORS, don't also add headers in Nginx, or you'll end up with duplicates.

Manual CORS implementation in vanilla Node.js:

server.js
const http = require('http');

const server = http.createServer((req, res) => {
  // Set CORS headers
  res.setHeader('Access-Control-Allow-Origin', 'https://myapp.com');
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  res.setHeader('Access-Control-Max-Age', '86400');

  // Handle preflight requests
  if (req.method === 'OPTIONS') {
    res.writeHead(204);
    res.end();
    return;
  }

  // Your actual route handling
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ message: 'Hello from API' }));
});

server.listen(3000);

Use the cors middleware rather than writing headers by hand:

app.js
const express = require('express');
const cors = require('cors');
const app = express();

// Simple usage - allow all origins (not for production!)
app.use(cors());

// Production-ready configuration
const corsOptions = {
  origin: ['https://myapp.com', 'https://admin.myapp.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
  credentials: true, // Allow cookies
  maxAge: 86400, // Cache preflight for 24 hours
  exposedHeaders: ['X-Request-Id'] // Headers client can access
};

app.use(cors(corsOptions));

// Dynamic origin based on request
const dynamicCors = cors({
  origin: (origin, callback) => {
    const allowedOrigins = ['https://myapp.com', 'https://staging.myapp.com'];
    
    // Allow requests with no origin (mobile apps, curl)
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  }
});

app.use(dynamicCors);

Laravel includes CORS middleware since version 7. Configure in config/cors.php:

config/cors.php
<?php

return [
    /*
     * Which paths should handle CORS
     */
    'paths' => ['api/*', 'sanctum/csrf-cookie'],

    /*
     * Matches request origins against patterns
     */
    'allowed_origins' => [
        'https://myapp.com',
        'https://admin.myapp.com',
    ],
    
    // Or use patterns
    'allowed_origins_patterns' => [
        'https://*.myapp.com',
    ],

    'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],

    'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With'],

    'exposed_headers' => ['X-Request-Id'],

    'max_age' => 86400,

    'supports_credentials' => true,
];

On Laravel 7 to 10, make sure HandleCors is in the global middleware stack in app/Http/Kernel.php. Laravel 11+ has no Kernel.php and registers it by default; run php artisan config:publish cors to get the config file.

Add CORS headers at the server level in Nginx:

nginx.conf
server {
    listen 443 ssl;
    server_name api.example.com;

    location /api/ {
        # CORS headers
        add_header 'Access-Control-Allow-Origin' 'https://myapp.com' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;
        add_header 'Access-Control-Max-Age' 86400 always;

        # Handle preflight requests
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' 'https://myapp.com';
            add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
            add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
            add_header 'Access-Control-Max-Age' 86400;
            add_header 'Content-Length' 0;
            add_header 'Content-Type' 'text/plain';
            return 204;
        }

        proxy_pass http://backend;
    }
}

CORS with Credentials

Once cookies or HTTP auth are involved, the rules get stricter. You need all three of these, and missing any one gives you a CORS error.

1

Server: Enable credentials

Set Access-Control-Allow-Credentials: true in response headers.

2

Server: Specify exact origin

You cannot use * for Allow-Origin when credentials are enabled. You have to send back the exact origin.

3

Client: Include credentials in request

Set credentials: 'include' in fetch or withCredentials: true in Axios.

client-with-credentials.js
// Fetch API
fetch('https://api.example.com/user', {
  method: 'GET',
  credentials: 'include', // Include cookies
  headers: {
    'Content-Type': 'application/json',
  }
});

// Axios
axios.get('https://api.example.com/user', {
  withCredentials: true // Include cookies
});

// Axios global config
axios.defaults.withCredentials = true;
Security Warning

The spec forbids Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true, and browsers reject the response if you try. Otherwise any website could read your API's responses using the visitor's cookies.

Common CORS Errors and Solutions

Error: No 'Access-Control-Allow-Origin' header

Cause: Server isn't sending CORS headers.

Solution: Add the Access-Control-Allow-Origin header to your server responses, on the routes actually being called. Also check the status code in the Network tab. If it's a 500 or 401, the real problem is often an error handler or auth middleware that returns before the CORS middleware adds its headers, so the browser reports a CORS error that's hiding the actual failure.

res.setHeader('Access-Control-Allow-Origin', 'https://your-frontend.com');

Error: Origin not in Access-Control-Allow-Origin

Cause: The requesting origin doesn't match the allowed origin.

Solution: The match is an exact string comparison on protocol, domain and port. Look for trailing slashes and www vs non-www.

// Wrong: port mismatch
'https://localhost:3000' !== 'https://localhost:3001'

// Wrong: trailing slash
'https://app.com/' !== 'https://app.com'

// Wrong: www mismatch  
'https://www.app.com' !== 'https://app.com'

Error: Request header not allowed

Cause: You're sending a custom header that isn't in Access-Control-Allow-Headers.

Solution: Add the header to your CORS configuration.

res.setHeader('Access-Control-Allow-Headers', 
  'Content-Type, Authorization, X-Custom-Header');

Error: Method not allowed

Cause: The HTTP method (PUT, DELETE, etc.) isn't in Access-Control-Allow-Methods.

Solution: Add the method to your allowed methods list.

res.setHeader('Access-Control-Allow-Methods', 
  'GET, POST, PUT, DELETE, PATCH, OPTIONS');

Error: Wildcard (*) with credentials

Cause: Using * for Allow-Origin while also requiring credentials.

Solution: Check the request's Origin against an allowlist and echo it back only if it matches.

const allowedOrigins = ['https://app.com', 'https://admin.app.com'];
const origin = req.headers.origin;

if (allowedOrigins.includes(origin)) {
  res.setHeader('Access-Control-Allow-Origin', origin);
  res.setHeader('Access-Control-Allow-Credentials', 'true');
}

Security Best Practices

Every line of a CORS config decides who can read your API's responses from a browser. Treat it like any other access rule:

CORS Security Checklist

  • No * on private APIs: list the exact origins. A truly public, read-only API without cookies is the one place * is fine.
  • Validate the Origin header: check it against an allowlist, don't blindly echo it back
  • Be specific with methods: only allow the HTTP methods your API actually uses
  • Limit allowed headers: only permit headers your frontend actually sends
  • Set a sensible Max-Age: caching preflight results cuts down on OPTIONS requests (browsers cap it, Chrome at 2 hours)
  • Only enable credentials if you need them: cookies or HTTP auth, nothing else
  • Keep CORS bypasses in development: proxies and browser flags aren't a production fix
  • Log rejected origins: they point to either a misconfiguration or someone probing
Don't Reflect the Origin Header

Setting Access-Control-Allow-Origin to whatever the request's Origin header says turns CORS off in practice, and with credentials enabled it lets any site read your users' data. Validate against an allowlist. Same goes for sloppy regexes: a pattern like myapp\.com$ also matches evilmyapp.com.

Development Workarounds

Locally you often just want the frontend to talk to the API. These are the options, from best to worst:

Proxy Server

Configure your dev server (Vite, Webpack, CRA) to proxy API requests. The proxy runs same-origin, so no CORS issues.

Dev-only CORS

Enable permissive CORS only in development mode. Use environment variables to control the configuration.

Browser Extensions

Extensions like "CORS Unblock" disable CORS for testing. Turn them off when you're done; they also hide bugs your users will hit.

Chrome Flags

Launch Chrome with --disable-web-security. Only for isolated testing environments.

vite.config.js - Proxy Example
export default {
  server: {
    proxy: {
      // Proxy /api requests to backend
      '/api': {
        target: 'https://api.example.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
}

Debugging CORS Issues

Start in the browser's dev tools, then confirm with cURL:

1

Check the Network Tab

Look at the actual request and response headers. Check that Origin, Access-Control-Allow-Origin and the other CORS headers are there and match. Also check the status code: a CORS error on a 500 is usually a server error first.

2

Look for Preflight (OPTIONS)

If your request triggers a preflight, check that the OPTIONS request succeeds and returns the CORS headers. If it fails, the real request never gets sent. Chrome sometimes hides OPTIONS requests unless you clear the type filter.

3

Test with cURL

Send the same headers the browser would and read the raw response. That takes caching, extensions and the browser itself out of the picture.

debug-cors.sh
# Check simple request headers
curl -I -H "Origin: https://myapp.com" \
  https://api.example.com/data

# Simulate preflight request
curl -X OPTIONS \
  -H "Origin: https://myapp.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization" \
  -I https://api.example.com/data

# Look for these in response:
# Access-Control-Allow-Origin: https://myapp.com
# Access-Control-Allow-Methods: POST, GET, ...
# Access-Control-Allow-Headers: Content-Type, Authorization

What to Check Before Going to Production

Things that bite after deploy

  • Do you need CORS at all? If you can serve the frontend and API from the same origin (say app.com and app.com/api behind one Nginx), you skip CORS and preflight latency entirely. I'd pick that over a clever CORS config whenever the architecture allows it.
  • Add Vary: Origin when the allowed origin is dynamic. Without it, a CDN or reverse proxy can cache a response with Access-Control-Allow-Origin: https://admin.myapp.com and serve it to myapp.com, which then fails at random. The cors package and Laravel add it for you; hand-written code usually doesn't.
  • Headers on error responses. Your 401s, 422s and 500s need CORS headers too, or the frontend can't read the error body and just shows "Failed to fetch". In Nginx that's what the always flag is for.
  • Exactly one Access-Control-Allow-Origin header. Setting it in both the app and Nginx produces "contains multiple values", which the browser rejects.
  • Staging and preview URLs. Make sure they're in the allowlist via environment config, not by loosening the production rule.

And remember what CORS doesn't do. It isn't authentication and it doesn't stop cURL, scripts or other servers from calling your API. It only controls what browsers let other sites read. Your API still needs real auth, and for cookie-based sessions, CSRF protection too.

CORS API Security Node.js Express Laravel Backend
Mayur Dabhi

Mayur Dabhi

Full-stack developer specializing in Laravel, React, and Node.js. Writing about web development, APIs, and best practices.