Back to HTTP Headers

CORS Headers cors both

The 9 headers that make Cross-Origin Resource Sharing work — controlling which origins, methods, and headers are allowed across origins.

What CORS is

CORS — Cross-Origin Resource Sharing — is the browser mechanism that controls whether JavaScript running on one origin (https://app.example.com) can read responses from a different origin (https://api.example.com).

The same-origin policy is the default: browsers block JavaScript from reading cross-origin responses. CORS is the controlled exception — a set of HTTP headers that let servers explicitly say "yes, this other origin is allowed to read my responses."

CORS is entirely a browser enforcement mechanism. Server-to-server requests, curl, Postman — none of them do CORS checks. If you're getting a CORS error, it's always the browser blocking your JavaScript, never a server-to-server issue.

The 9 CORS headers

Request headers (browser → server)

These are set automatically by the browser. You never set them manually.

Origin

Every cross-origin request includes Origin, telling the server where the request came from:

Origin: https://app.example.com

Also sent on same-origin requests in some contexts (form submissions, fetch with credentials). The server uses this to decide whether to allow the request.

Access-Control-Request-Method

Sent on preflight requests (OPTIONS) only. Tells the server which HTTP method the actual request will use:

Access-Control-Request-Method: DELETE

Access-Control-Request-Headers

Sent on preflight requests only. Lists the non-simple headers the actual request will include:

Access-Control-Request-Headers: Content-Type, Authorization, X-Custom-Header

Response headers (server → browser)

These are what you set on your server to grant CORS access.

Access-Control-Allow-Origin

The most important CORS header. Specifies which origin(s) can read the response:

Access-Control-Allow-Origin: *
Access-Control-Allow-Origin: https://app.example.com
  • * — any origin can read this (but not with credentials)
  • A specific origin — only that origin can read this
  • Cannot be a list — only one value

To allow multiple origins dynamically: check the Origin request header against an allowlist, then echo it back:

$allowed = ['https://app.example.com', 'https://admin.example.com'];
if (in_array($_SERVER['HTTP_ORIGIN'], $allowed)) {
    header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
    header('Vary: Origin');
}

Always add Vary: Origin when reflecting origins dynamically — otherwise caches may serve the wrong origin's response.

Access-Control-Allow-Methods

On preflight responses. Lists the HTTP methods permitted for cross-origin requests:

Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS

Access-Control-Allow-Headers

On preflight responses. Lists the request headers permitted for cross-origin requests:

Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With

Access-Control-Allow-Credentials

Whether the browser should expose the response to JavaScript when the request includes credentials (cookies, HTTP auth):

Access-Control-Allow-Credentials: true

Critical constraints when using credentials:

  • Cannot use Access-Control-Allow-Origin: * — must specify the exact origin
  • The request must include credentials: 'include' in fetch options
  • Both conditions must be true or the browser blocks the response

Access-Control-Expose-Headers

By default, JavaScript can only read these "safe" response headers: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma. To expose others:

Access-Control-Expose-Headers: X-RateLimit-Remaining, X-Request-Id

Access-Control-Max-Age

How long (in seconds) the browser should cache a preflight response. Avoids sending an OPTIONS request before every actual request:

Access-Control-Max-Age: 86400

Chrome caps this at 7200 (2 hours); Firefox caps at 86400 (24 hours). Set it as high as the browser allows.

Simple vs preflighted requests

Not all cross-origin requests trigger a preflight.

Simple requests — sent directly, no preflight:

  • Methods: GET, HEAD, POST only
  • Headers: only Accept, Accept-Language, Content-Language, Content-Type (with restrictions)
  • Content-Type: only application/x-www-form-urlencoded, multipart/form-data, text/plain

Preflighted requests — browser sends OPTIONS first:

  • Any other method (PUT, DELETE, PATCH...)
  • Any custom header (Authorization, X-Custom-Header...)
  • Content-Type: application/json

The preflight flow:

Browser → Server: OPTIONS /api/resource
  Origin: https://app.example.com
  Access-Control-Request-Method: DELETE
  Access-Control-Request-Headers: Authorization

Server → Browser: 200 OK
  Access-Control-Allow-Origin: https://app.example.com
  Access-Control-Allow-Methods: GET, POST, DELETE
  Access-Control-Allow-Headers: Authorization
  Access-Control-Max-Age: 86400

Browser → Server: DELETE /api/resource
  Origin: https://app.example.com
  Authorization: Bearer token

Server → Browser: 200 OK
  Access-Control-Allow-Origin: https://app.example.com

A complete CORS setup for a JSON API

// Laravel middleware or plain PHP
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed_origins = ['https://app.example.com', 'https://admin.example.com'];

if (in_array($origin, $allowed_origins)) {
    header('Access-Control-Allow-Origin: ' . $origin);
    header('Vary: Origin');
}

header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Max-Age: 86400');
header('Access-Control-Expose-Headers: X-RateLimit-Remaining, X-Request-Id');

// Handle preflight
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(204);
    exit;
}

Common mistakes — why you're getting that CORS error

Access-Control-Allow-Origin: * with credentials. Doesn't work. When sending cookies or Authorization headers, you must specify the exact origin. Full stop.

Forgetting to handle OPTIONS preflight. Your API returns 404 or 405 on OPTIONS requests, so the preflight fails and the real request never fires. Handle OPTIONS and return the CORS headers with a 204 No Content.

Setting CORS headers in the wrong place. If your framework adds CORS headers but an authentication middleware returns 401 before the CORS middleware runs, the 401 response has no CORS headers — the browser sees a CORS error instead of an auth error, which is super confusing to debug.

Not including the correct header in Access-Control-Allow-Headers. If your request sends Authorization: Bearer token but your server's Access-Control-Allow-Headers doesn't include Authorization, the preflight fails. Every non-simple request header must be listed.

Forgetting Vary: Origin when reflecting origins dynamically. A CDN that caches your response without Vary: Origin will serve one origin's allowed response to all origins. Users from other origins see stale Access-Control-Allow-Origin values.

CORS is browser-only — a reminder

This is so important it bears repeating. CORS errors you see in the browser console are the browser refusing to expose a response to JavaScript. The server received the request and responded — the browser just won't let your JS read it. This is why:

  • The same request works in Postman/curl but fails in your browser
  • Server logs show the request arrived and a 200 was returned
  • The fix is always on the server side (adding CORS headers), not the browser side

FAQ

Why can't I just use * for everything?

You can for public APIs that don't use credentials. But * + Access-Control-Allow-Credentials: true is forbidden by spec. If your API uses cookies or Bearer tokens, you need to specify exact origins. The restriction exists because * + credentials would let any random website make authenticated requests to your API on behalf of your users — that's basically CSRF at the API level.

Does CORS protect my server?

Not directly. CORS controls which JavaScript can read cross-origin responses. The request still reaches your server — CORS just stops the JS on the other page from seeing the response. Actual protection requires authentication, authorisation, and CSRF protection.

Why does my same-origin request not need CORS headers?

Same-origin policy only restricts cross-origin reads. If JavaScript on https://app.example.com calls https://app.example.com/api, same origin, no CORS required. CORS only matters when the scheme, host, or port differs.

Do CORS headers need to be on every response or just preflight?

Both. The preflight (OPTIONS) response needs Access-Control-Allow-Methods and Access-Control-Allow-Headers. The actual response needs Access-Control-Allow-Origin (and Access-Control-Allow-Credentials if applicable). Some headers only make sense on preflight; Access-Control-Allow-Origin must be on the actual response the JS wants to read.

Fun fact

CORS replaced a browser security hack called JSONP (JSON with Padding) — a technique that exploited the fact that <script src="..."> tags aren't subject to the same-origin policy. APIs would return JavaScript function calls (callback({"data": ...})) that executed in the browser, passing data without triggering CORS checks. It was fragile, only worked for GET requests, and was a security nightmare. CORS was standardised in 2014 specifically to give browsers a proper mechanism so developers could stop using JSONP. Yet you can still find JSONP support in plenty of legacy APIs today.