Introduction
When your JavaScript frontend at app.example.com tries to call your API at api.example.com, the browser blocks the request by default. This is the Same-Origin Policy in action. Cross-Origin Resource Sharing (CORS) is the mechanism that tells browsers which cross-origin requests to allow.
Key Concepts
- Same-Origin Policy: A browser security feature that prevents JavaScript from making requests to a different origin (protocol + domain + port) than the page it was loaded from.
- CORS (Cross-Origin Resource Sharing): A protocol that uses HTTP headers to tell browsers which cross-origin requests are permitted.
- Preflight Request: An automatic
OPTIONSrequest the browser sends before certain cross-origin requests to check if the server allows the actual request. - Simple Request: A cross-origin request that uses only GET, HEAD, or POST with standard headers, which does not trigger a preflight.
- Access-Control Headers: HTTP headers like
Access-Control-Allow-OriginandAccess-Control-Allow-Methodsthat configure CORS policy.
Real World Context
Every single-page application (SPA) that communicates with a separate API backend needs CORS configuration. Without it, your React, Vue, or Angular frontend cannot make API calls to your PHP backend if they are on different domains or ports. Misconfigured CORS is one of the most common deployment issues for web APIs.
Deep Dive
How CORS Works
CORS uses a handshake between the browser and server. For non-simple requests, the browser sends a preflight OPTIONS request first. The server responds with headers indicating what is allowed.
Here is a diagram of the flow:
- Browser sends
OPTIONS /api/productswith origin and requested method/headers. - Server responds with
Access-Control-Allow-*headers. - If allowed, browser sends the actual
POST /api/productsrequest. - Server includes
Access-Control-Allow-Originin the response.
Building CORS Middleware
A CORS middleware intercepts requests and adds the appropriate headers. Here is a complete, configurable CORS middleware:
php<?php class CorsMiddleware { /** @param string[] $allowedOrigins */ public function __construct( private readonly array $allowedOrigins = ['*'], private readonly array $allowedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], private readonly array $allowedHeaders = ['Content-Type', 'Authorization', 'X-Requested-With'], private readonly int $maxAge = 86400, private readonly bool $allowCredentials = false, ) {} public function handle(callable $next): void { $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; if (!$this->isOriginAllowed($origin)) { if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(403); exit; } $next(); return; } // Set CORS headers header("Access-Control-Allow-Origin: {$origin}"); header('Access-Control-Allow-Methods: ' . implode(', ', $this->allowedMethods)); header('Access-Control-Allow-Headers: ' . implode(', ', $this->allowedHeaders)); header("Access-Control-Max-Age: {$this->maxAge}"); if ($this->allowCredentials) { header('Access-Control-Allow-Credentials: true'); } // Handle preflight if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; } $next(); } private function isOriginAllowed(string $origin): bool { if (in_array('*', $this->allowedOrigins, true)) { return true; } return in_array($origin, $this->allowedOrigins, true); } }
The middleware checks the Origin header, validates it against the allowed list, and sets the appropriate Access-Control-* response headers. Preflight OPTIONS requests return a 204 No Content response immediately without processing the request further.
Configuring CORS per Environment
Development and production environments typically need different CORS settings. Here is how to configure the middleware based on the environment:
php<?php // Environment-specific CORS configuration $corsConfig = match ($_ENV['APP_ENV'] ?? 'production') { 'development' => [ 'origins' => ['http://localhost:3000', 'http://localhost:4200'], 'credentials' => true, ], 'staging' => [ 'origins' => ['https://staging.myapp.com'], 'credentials' => true, ], 'production' => [ 'origins' => ['https://myapp.com', 'https://www.myapp.com'], 'credentials' => true, ], }; $cors = new CorsMiddleware( allowedOrigins: $corsConfig['origins'], allowCredentials: $corsConfig['credentials'], ); // Apply to all API routes $cors->handle(function () use ($router) { $router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']); });
In development, you allow localhost origins. In production, you lock it down to your exact domains. The match expression keeps the configuration clean and readable.
Exposing Custom Headers
By default, browsers only expose a small set of response headers to JavaScript. If your API returns custom headers (like pagination cursors or rate limit info), you must explicitly expose them:
php<?php // Allow JavaScript to read these custom response headers header('Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Remaining, Link');
Without this header, fetch() in the browser can see the response body but not your custom headers. This is a frequently overlooked CORS detail.
Common Pitfalls
- Using
Access-Control-Allow-Origin: *with credentials — Browsers reject this combination. WhenAccess-Control-Allow-Credentials: trueis set, you must echo back the specific origin from the request, not the wildcard*. - Forgetting to handle OPTIONS requests — If your router does not handle
OPTIONSmethod requests, preflight checks fail with a 404 or 405, and the browser blocks all non-simple cross-origin requests.
Best Practices
- Whitelist specific origins — Avoid
Access-Control-Allow-Origin: *in production. Maintain an explicit list of allowed origins to prevent unauthorized domains from calling your API. - Cache preflight responses — Set
Access-Control-Max-Ageto a reasonable value (e.g., 86400 seconds = 24 hours) so browsers do not send a preflight request before every single API call.
Summary
- The Same-Origin Policy blocks cross-origin requests by default; CORS headers tell browsers which exceptions to allow.
- Preflight
OPTIONSrequests happen automatically for non-simple requests (custom headers, non-GET/POST methods). - A CORS middleware centralizes header management and handles preflight responses.
- Never use wildcard origins (
*) with credentials; echo the specific origin instead. - Use
Access-Control-Expose-Headersto make custom response headers visible to JavaScript.
Code Examples
<?php
// CORS middleware with environment-specific configuration
$cors = new CorsMiddleware(
allowedOrigins: ['https://myapp.com', 'https://admin.myapp.com'],
allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
maxAge: 86400,
allowCredentials: true,
);
// The middleware:
// 1. Validates the Origin header against the allowed list
// 2. Sets Access-Control-Allow-* response headers
// 3. Handles preflight OPTIONS requests with 204 No Content
// 4. Passes non-preflight requests to the next handler
$cors->handle(function () use ($router) {
$router->dispatch(
$_SERVER['REQUEST_METHOD'],
$_SERVER['REQUEST_URI']
);
});