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: onlyapplication/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.