How HTTP Works

Guide

Cross-Origin Resource Sharing (CORS)

Master Cross-Origin Resource Sharing (CORS) for secure cross-origin HTTP requests. Learn preflight requests, headers, credentials, and common error solutions.

Reviewed 14 min readintermediate4 sourcesTry itMarkdown
On this page

TL;DR: CORS is how a server tells the browser that JavaScript from another origin may read its responses, mainly with Access-Control-Allow-Origin. Requests with JSON bodies, Authorization or methods like PUT and DELETE get an OPTIONS preflight first; simpler requests go straight through, and the browser only blocks reading the response. CORS errors are fixed on the server, so check the headers on both the preflight and the real response, including errors.

Introduction

An origin is the scheme, host and port together. https://app.example.com and https://api.example.com are different origins, even though they’re the same site. The path doesn’t matter.

Request URL, from https://app.example.comSame origin?
https://app.example.com/api/profileYes; only the path differs.
https://api.example.com/profileNo; the host differs.
http://app.example.com/profileNo; the scheme differs.
https://app.example.com:8443/profileNo; the port differs.

CORS is enforced by browsers, for APIs like Fetch and XMLHttpRequest. It isn’t authentication, and curl ignores it completely.

Why CORS Exists

Browsers stop a page on one origin from reading data from another; that’s the same-origin policy. CORS lets a server opt in and say “this origin may read my responses.” It’s about reading, not sending. A plain GET or a form-style POST reaches your server and runs before the browser decides whether the page gets to see the response.

So your server still has to authorize every action and protect cookie-authenticated writes against CSRF. A CORS error in the console doesn’t mean the request did nothing.

How CORS Works

On a cross-origin fetch, the browser adds an Origin header. When the response comes back, the browser checks its CORS headers before handing the body to JavaScript.

The relevant headers on a response without credentials (examples on this page are trimmed):

Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
Content-Type: application/json

If your server picks the allow-origin value based on the request’s Origin, send Vary: Origin on every response from that endpoint, including ones that grant nothing. That stops a cache from serving one origin’s response to another. It’s a caching instruction, not a security control.

Simple Requests vs Preflight Requests

A “simple request” is one that skips preflight under the Fetch rules. It has to use a safelisted method (GET, HEAD, or POST) and only safelisted headers: Accept, Accept-Language, Content-Language, Content-Type with certain values, and Range with a single byte range. The values have length and syntax limits too, so the header name alone isn’t enough.

The allowed Content-Type values are application/x-www-form-urlencoded, multipart/form-data, and text/plain. JSON isn’t one of them, which is why so many API calls get preflighted. PUT, PATCH, DELETE, an Authorization header, or any custom header also trigger a preflight on a cross-origin fetch, unless the browser has a cached permission. Same-origin requests never get preflighted, JSON or not.

await fetch('https://api.example.com/profile', {
  headers: { Authorization: 'Bearer example-token' }
})

Before that GET, the browser asks permission to send an authorization header. The preflight lists header names only; the token itself isn’t sent. See the Fetch CORS protocol.

Essential CORS Headers

Access-Control-Allow-Origin takes one origin or *. A comma-separated list doesn’t work. To support several origins, check the request’s Origin against an allowlist, echo back the one that matched, and send Vary: Origin.

Access-Control-Allow-Methods answers the preflight’s method question; the regular Allow header doesn’t count. Access-Control-Allow-Headers lists the non-safelisted request headers you accept. List Authorization by name, because * never covers it, with or without cookies.

By default JavaScript can only read a few safelisted response headers. Access-Control-Expose-Headers adds more. To let it read ETag and Location:

Access-Control-Expose-Headers: ETag, Location

Set-Cookie is the exception: Fetch always hides it from JavaScript, whatever you list.

Access-Control-Max-Age says how many seconds the browser may cache a preflight answer. Without it (or with an invalid value), Fetch uses five seconds. Browsers also apply their own upper limit, so asking for 86400 won’t necessarily get you a day. This preflight cache is separate from the HTTP cache.

Credentialed Requests

Fetch defaults to credentials: 'same-origin', so cookies aren’t sent cross-origin unless you ask:

const response = await fetch('https://api.example.com/profile', {
  credentials: 'include'
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
console.log(await response.json())

With credentials: 'include', the response has to name the exact origin and send Access-Control-Allow-Credentials: true. A * origin fails, even if there’s no cookie to send. The preflight for such a request needs the same two headers, even though the preflight itself carries no cookies.

Access-Control-Allow-Credentials only lets JavaScript read the response. It doesn’t change the request’s credentials mode, and it can’t make the browser send a cookie it would otherwise withhold. Cookie scope, SameSite, Secure and third-party cookie policy all still apply. A cross-site fetch only sends a cookie marked SameSite=None; Secure. Two subdomains of the same site are different origins but the same site, so they don’t need that.

Setting an Authorization header yourself triggers a preflight, but it doesn’t switch the request to credentials: 'include'. Those are two separate things.

Server Implementation Examples

This runnable Express server shares a public, profile-shaped response with two allowed origins. It shows the CORS headers only; there’s no login or authorization. Replace the origins with your real frontend.

const express = require('express')
const app = express()
const origins = new Set(['https://app.example.com', 'https://admin.example.com'])

app.use('/profile', (req, res, next) => {
  res.vary('Origin')
  const origin = req.get('Origin')
  if (origins.has(origin)) {
    res.set({
      'Access-Control-Allow-Origin': origin,
      'Access-Control-Allow-Credentials': 'true',
      'Access-Control-Expose-Headers': 'ETag'
    })
  }
  next()
})
app.options('/profile', (req, res) => {
  if (!origins.has(req.get('Origin'))) return res.status(403).end()
  res.set({
    'Access-Control-Allow-Methods': 'GET, HEAD',
    'Access-Control-Allow-Headers': 'Authorization, Content-Type',
    'Access-Control-Max-Age': '600' // Chosen example permission lifetime.
  })
  res.status(204).end()
})
app.get('/profile', (_req, res) => {
  res.set('Cache-Control', 'no-store').json({ displayName: 'Example' })
})
app.listen(3000)

Put the CORS handling before any middleware that can reject requests, so allowed origins can read your error responses too. Handle preflight before cookie or Bearer authentication, since the preflight carries neither. Authenticate the actual request afterwards. For an origin you don’t allow, leaving out the headers blocks browsers, but curl can still call a public endpoint; it doesn’t care.

Express with the cors middleware

The maintained cors middleware saves you writing those checks by hand. Use this instead of the manual handlers above, mounted before authentication and your API routes:

const cors = require('cors')

app.use('/api', cors({
  origin: ['https://app.example.com', 'https://admin.example.com'],
  methods: ['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
  exposedHeaders: ['ETag', 'Location'],
  credentials: true,
  maxAge: 600
}))
app.use('/api', express.json())

The middleware answers preflights itself, so you don’t need a separate catch-all OPTIONS route. Listing PATCH here only tells browsers they may send it; you still need a PATCH handler. Keep the list in sync with your routes. If the frontend sends If-Match, add it to allowedHeaders too.

Flask

This Flask-Cors example allows two origins on one path. Install Flask and Flask-Cors and run it with your usual Flask server:

from flask import Flask, jsonify
from flask_cors import CORS

app = Flask(__name__)
CORS(app, resources={r"^/api/profile$": {
    "origins": ["https://app.example.com", "https://admin.example.com"],
    "methods": ["GET", "HEAD"],
    "allow_headers": ["Authorization"],
    "expose_headers": ["ETag"],
    "supports_credentials": True,
    "max_age": 600,
}})

@app.get("/api/profile")
def profile():
    response = jsonify(displayName="Example")
    response.headers["Cache-Control"] = "no-store"
    return response

The resources keys are regular expressions, not glob patterns. This route returns a public value; add authentication before you return real account data. Make sure 401 responses carry the CORS headers too, or the frontend gets an opaque CORS failure instead of a 401 it can handle.

Spring MVC

In Spring MVC, register a per-path policy through WebMvcConfigurer. This class configures your existing controllers; it doesn’t create any endpoints:

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("https://app.example.com")
            .allowedMethods("GET", "HEAD", "PUT")
            .allowedHeaders("Content-Type", "Authorization", "If-Match")
            .exposedHeaders("ETag")
            .allowCredentials(true)
            .maxAge(600);
    }
}

If Spring Security sees requests before MVC does, enable its CORS support as the Spring docs describe. Otherwise the security filter rejects the preflight before your MVC config ever runs.

PHP

In a small PHP endpoint, set the headers before any output and before authentication. Save this GET-only example as profile.php:

<?php
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed = ['https://app.example.com', 'https://admin.example.com'];
header('Vary: Origin');
header('Cache-Control: no-store');
if (in_array($origin, $allowed, true)) {
    header('Access-Control-Allow-Origin: ' . $origin);
    header('Access-Control-Allow-Credentials: true');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    if (!in_array($origin, $allowed, true)) {
        http_response_code(403);
        exit;
    }
    header('Access-Control-Allow-Methods: GET, HEAD');
    header('Access-Control-Allow-Headers: Authorization');
    header('Access-Control-Max-Age: 600');
    http_response_code(204);
    exit;
}
if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) {
    header('Allow: GET, HEAD, OPTIONS');
    http_response_code(405);
    exit;
}
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'HEAD') {
    echo json_encode(['displayName' => 'Example']);
}

PHP’s header function only works before output, and even a stray blank line before <?php counts as output. By default it replaces an earlier header with the same name. Leave it that way for allow-origin; passing false would append a second value, which browsers reject.

Go middleware

In Go, wrap the API handler outside its authentication middleware. This wrapper lets one frontend make GET and HEAD requests, including ones with a Bearer header:

// Imports: "net/http".
func cors(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Add("Vary", "Origin")
        allowed := r.Header.Get("Origin") == "https://app.example.com"
        if allowed {
            w.Header().Set("Access-Control-Allow-Origin", "https://app.example.com")
        }
        if r.Method == http.MethodOptions &&
            r.Header.Get("Access-Control-Request-Method") != "" {
            if !allowed {
                http.Error(w, "Origin not allowed", http.StatusForbidden)
                return
            }
            w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD")
            w.Header().Set("Access-Control-Allow-Headers", "Authorization")
            w.Header().Set("Access-Control-Max-Age", "600")
            w.WriteHeader(http.StatusNoContent)
            return
        }
        next.ServeHTTP(w, r)
    })
}

Mount it as cors(apiHandler). Note the net/http Header methods: Add for Vary, which can hold several values, and Set for allow-origin, which must have exactly one. The wrapper has to run before any middleware that rejects unauthenticated OPTIONS requests. Your routes still enforce their own methods and access checks.

There’s no allow-credentials here, because it’s meant for fetches that don’t use credentials: 'include'. A Bearer header you set yourself still needs the explicit Authorization entry shown, even though the preflight carries no token.

Reverse proxies: nginx, Apache, and Caddy

Pick one place to own CORS. If the application does it, let the proxy pass the headers through. If the proxy does it, strip the application’s. Two layers setting their own policy is a classic source of duplicate allow-origin headers and error responses the browser won’t let you read.

For nginx, this location block lets one frontend use a GET-only upstream. Put it inside an existing server block:

location /api/ {
    proxy_hide_header Access-Control-Allow-Origin;
    proxy_hide_header Access-Control-Allow-Credentials;
    add_header Access-Control-Allow-Origin "https://app.example.com" always;
    add_header Access-Control-Allow-Credentials "true" always;
    add_header Access-Control-Allow-Methods "GET, HEAD" always;
    add_header Access-Control-Allow-Headers "Authorization" always;
    add_header Access-Control-Max-Age "600" always;
    if ($request_method = OPTIONS) { return 204; }
    proxy_pass http://127.0.0.1:3000;
}

This sends the same fixed origin to everyone, so browsers on any other origin are blocked. always adds the headers to error responses too. Watch nested locations: a nested block with its own add_header lines stops inheriting the parent’s. The block also answers every OPTIONS request under /api/ itself, so only use it where that’s what you want.

Apache’s mod_headers keeps two separate header tables, onsuccess and always. Clear the upstream’s values from both before setting your own:

<Location "/api/">
    Header onsuccess unset Access-Control-Allow-Origin
    Header always unset Access-Control-Allow-Origin
    Header always set Access-Control-Allow-Origin "https://app.example.com"
    Header onsuccess unset Access-Control-Allow-Credentials
    Header always unset Access-Control-Allow-Credentials
    Header always set Access-Control-Allow-Credentials "true"
    Header always set Access-Control-Allow-Methods "GET, HEAD"
    Header always set Access-Control-Allow-Headers "Authorization"
    Header always set Access-Control-Max-Age "600"
</Location>

mod_headers has to be enabled. These directives only add headers; they can’t turn a 401 preflight into a success. The upstream needs to answer OPTIONS with a 2xx before it checks authentication. Check the headers on upstream errors, not just on normal responses.

In Caddy, mutually exclusive handle blocks separate preflights from proxied requests. This config assumes the proxy owns CORS:

api.example.com {
    handle /api/* {
        header {
            >Access-Control-Allow-Origin https://app.example.com
            >Access-Control-Allow-Credentials true
        }
        @preflight {
            method OPTIONS
            header Origin https://app.example.com
            header Access-Control-Request-Method *
        }
        handle @preflight {
            header Access-Control-Allow-Methods "GET, HEAD"
            header Access-Control-Allow-Headers Authorization
            header Access-Control-Max-Age 600
            respond "" 204
        }
        handle {
            reverse_proxy 127.0.0.1:3000
        }
    }
}

The > prefix delays setting the header until the response goes out, so it overrides whatever the upstream sent. Preflights from other origins don’t match @preflight. And again, allowing a method doesn’t implement it; the upstream still enforces its own methods and authorization.

Real-World Scenarios

A public, read-only API can send Access-Control-Allow-Origin: *, as long as clients don’t use credentials: 'include'. An API that serves account data should check the origin against an allowlist, set its credentials policy deliberately, and still authorize every request.

For a FormData upload, let Fetch set Content-Type itself so the multipart boundary is right. multipart/form-data is safelisted, but adding a custom X-File-Name header or an XMLHttpRequest upload progress listener triggers a preflight anyway. WebSockets have their own handshake and Origin check; CORS response headers don’t apply to them.

Here’s a cookie-authenticated profile update from the browser:

const response = await fetch('https://api.example.com/profile', {
  method: 'PATCH',
  credentials: 'include',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': csrfToken // Obtained from the application's CSRF flow.
  },
  body: JSON.stringify({ displayName: 'Avery' })
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)

The preflight asks for PATCH with content-type and x-csrf-token. Passing it authenticates nothing; the real PATCH must carry the cookie and a valid CSRF token. If the session has expired, the server returns 401, and that response needs the allow-origin and allow-credentials headers too, or this code can’t even see the 401.

Images work differently. An <img> from another origin displays fine, but JavaScript can’t fetch its bytes, and drawing it onto a canvas locks you out of reading the pixels. If you need canvas access, set the image’s crossOrigin property before setting src, and have the server send the CORS headers. See MDN’s canvas guidance.

Troubleshooting Common CORS Errors

Each browser words these differently. MDN documents these reasons, among others:

Reason: CORS header 'Access-Control-Allow-Origin' missing
Reason: Multiple CORS header 'Access-Control-Allow-Origin' not allowed
Reason: Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*'

For a missing header, check the failing response, not just a working one. A 502 from the proxy or a 401 from your auth layer often skips the code that adds CORS headers. For duplicate values, look for a proxy and the application both adding the header. Express res.set() replaces the value, so calling it twice in Express won’t produce two headers.

A preflight has to get a 2xx. A 401, 403 or redirect stops the actual request from ever being sent, so keep OPTIONS away from your login redirect. 200 and 204 both work, as long as the headers are there.

When a method or header is rejected, compare the names in the preflight request with the names in the response. A DELETE needs DELETE in the allowed methods. A request that sends If-Match needs if-match in the allowed headers; exposing ETag on responses doesn’t cover it, because that’s the other direction.

If the fetch succeeds but response.headers.get('ETag') returns null, the header probably isn’t exposed. Confirm in DevTools that the server sent it, then add ETag to Access-Control-Expose-Headers. With credentials: 'include', list headers by name; the * wildcard doesn’t apply.

Redirects need their own check. If the frontend calls an http:// URL or an old path, point it at the final HTTPS URL directly. Then see where OPTIONS actually landed: a trailing-slash redirect, a login redirect, or your API handler. Adding headers to the final GET won’t help if the preflight already failed.

Debugging CORS Issues

In DevTools, look at the request’s Origin and, if there’s an OPTIONS request, its Access-Control-Request-Method and Access-Control-Request-Headers. Compare those with what the preflight response allowed, then check the CORS headers on the actual response. No OPTIONS request at all means either no preflight was needed or the browser reused a cached one.

Reproduce both exchanges with curl. Keep the first one a GET rather than switching to HEAD:

curl -i 'https://api.example.com/profile' \
  -H 'Origin: https://app.example.com'

curl -i -X OPTIONS 'https://api.example.com/profile' \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: GET' \
  -H 'Access-Control-Request-Headers: authorization'

This shows you the headers the server sends; it can’t tell you what the browser will allow. For that, run the fetch from your real frontend origin.

mode: 'no-cors' won’t fix a JSON API call. It gives you an opaque response with status 0 and no readable headers or body. Adding Access-Control-Allow-Origin to the request doesn’t help either; it’s a response header, and only the server can send it.

When you test a policy change, remember the preflight cache. A cached answer means no OPTIONS request appears, while the actual response still gets checked. Use a fresh browser profile or private window to see the full exchange, and compare requests with the same origin, credentials mode, method and headers.

To debug a server error, reproduce the exact request the browser sends. An anonymous GET that works tells you little about a cookie-authenticated PATCH that runs through different middleware. Check the Network panel’s initiator and redirect chain along with the final status. The rejected fetch promise in JavaScript won’t show you a response the browser refused to share.

Origin Validation and CSRF

Never echo back any Origin you receive while also sending allow-credentials; that lets every website read your users’ data. Resist allowing null to make an error go away, too: sandboxed documents and other opaque origins send null, so an attacker can produce it. And an origin allowlist is no substitute for per-user authorization or CSRF checks.

Match origins exactly. https://app.example.com.evil.test isn’t your app, but a substring check like origin.includes('example.com') would accept it. For a short allowlist, compare full origins. For subdomain rules, parse the URL and check the scheme and hostname properly instead of searching the raw string. Add development ports on purpose: http://localhost:3000 and http://localhost:5173 are different origins.

Performance Optimization

Pick a preflight cache lifetime based on how quickly policy changes need to take effect. Switching JSON to form encoding, or Bearer tokens to cookies, just to dodge OPTIONS is a bad trade; it changes how requests are parsed and secured. Cache preflights where it makes sense, and set the actual response’s caching separately.

For example, a ten-minute preflight cache on a response that shouldn’t be stored:

Access-Control-Max-Age: 600
Cache-Control: no-store

The browser can reuse the preflight answer for up to ten minutes (or its own limit, if lower), while no-store keeps the response itself out of the HTTP cache. They control different caches. When you remove an allowed origin, stop granting it on actual responses right away rather than waiting for cached preflights to expire.

Check your understanding

Guided Lab

Trigger and inspect a preflight request

Use the CORS debugger to compare a simple cross-origin request with one that triggers preflight because of JSON and Authorization headers.

Open CORS Debugger
  1. Start with a simple GET request and note which headers are sufficient for the browser to allow it.
  2. Add `Authorization` or `Content-Type: application/json` and observe how the debugger introduces a preflight OPTIONS request.
  3. Adjust the allow-origin, allow-methods, and allow-headers values until the simulated request succeeds.

Look for: You should see that preflight is caused by request shape, not by the server randomly deciding to be strict.

Why it matters: Most CORS debugging is really a comparison between what the browser wants to send and what the server explicitly allows.

Interactive Check

Spot the invalid CORS configuration

A browser request includes credentials, and the server responds with `Access-Control-Allow-Origin: *`. What is the problem?

Spot the invalid CORS configuration

Frequently asked questions

What is CORS?

CORS is how a server tells the browser that JavaScript from another origin may read its responses. It controls reading, not sending, so you still need authentication, authorization and CSRF protection.

Why do I get CORS errors?

Usually because the preflight or the actual response is missing a CORS header, or has the wrong value. Network failures and server errors can show up as CORS errors too. Check the Network panel, and keep in mind the request may well have reached the server.

What is a preflight request?

An OPTIONS request the browser sends before some cross-origin requests to ask whether they are allowed, unless it has a cached answer. It names the method and headers the real request will use, but carries no cookies or Bearer token.

How do I fix CORS errors?

Fix the headers on the server, on both the preflight and the actual response, including error responses. With credentials: include, name the exact origin and send Access-Control-Allow-Credentials: true. Any non-safelisted request header, such as Authorization, has to be allowed explicitly.

Sources

  1. MDN Web Docs: CORSdeveloper.mozilla.org
  2. Fetch Standard: CORS Protocolfetch.spec.whatwg.org
  3. RFC 9110: OPTIONSrfc-editor.org
  4. MDN: CORS diagnostic reasonsdeveloper.mozilla.org

Keep going

Browse /search