Same-Origin Policy, preflight, and secure origin allowlists
A permissively set Access-Control-Allow-Origin header lifts exactly the browser protection the Same-Origin Policy is meant to guarantee. This article explains preflight requests, the dangerous combination of wildcard and credentials, origin reflection as the most common mistake, and how a clean, server-side allowlist keeps working reliably for Magento APIs behind a CDN and Varnish.
Table of Contents
- 1. Same-Origin Policy: the default protection CORS deliberately relaxes
- 2. Simple Requests vs. Preflighted Requests
- 3. The preflight request in detail: OPTIONS, methods, headers, and max-age
- 4. Access-Control-Allow-Origin: wildcard risks and the credentials catch
- 5. Reflecting the origin without an allowlist: the most common mistake
- 6. Implementing a server-side allowlist correctly
- 7. CORS for Magento GraphQL and REST APIs in a headless setup
- 8. Vary: Origin and the caching pitfalls with CDNs and Varnish
- 9. CORS configurations compared
- 10. Summary
- 11. FAQ
1. Same-Origin Policy: the default protection CORS deliberately relaxes
The Same-Origin Policy (SOP) is the default protection mechanism of every modern browser, not an optional setting. It prevents JavaScript running on a page at origin-a.com from reading the response of a resource at origin-b.com via fetch or XMLHttpRequest, even if the request is technically sent and answered. Origin here means the exact combination of scheme, host, and port: https://shop.example.com and https://api.example.com count as two different origins, and so do http://example.com and https://example.com. Without SOP, any malicious website could quietly send requests to a bank API in the background and read the response containing the logged-in user's data.
Cross-Origin Resource Sharing (CORS) is not a replacement for the Same-Origin Policy, but a deliberately controlled exception mechanism through which a server signals, via HTTP headers, which foreign origins are allowed to access its responses. Every CORS configuration therefore opens a targeted hole in a protection mechanism that denies everything by default. That is exactly why care matters: a permissively set Access-Control-Allow-Origin header lifts the browser protection for precisely the attacks the Same-Origin Policy was originally built to prevent.
2. Simple Requests vs. Preflighted Requests
Not every cross-origin request triggers an upfront check. Browsers distinguish between Simple Requests and Preflighted Requests, and this distinction determines how an API must respond to cross-origin access in the first place. A Simple Request meets strict criteria: it uses only GET, HEAD, or POST, sets no custom headers, and limits Content-Type to application/x-www-form-urlencoded, multipart/form-data, or text/plain. Such requests are sent directly to the server by the browser, and the CORS check only happens when the response headers are evaluated.
As soon as a request steps outside this narrow frame, for example through PUT, DELETE, PATCH, an Authorization header, or Content-Type: application/json, which practically every modern REST or GraphQL API requires, it becomes a Preflighted Request. The browser then automatically sends an OPTIONS request first to clarify whether the actual request is allowed before any payload is transferred at all. For Magento APIs, which almost always work with JSON, the preflight is therefore the norm, not the exception, and must be answered correctly on the server side.
3. The preflight request in detail: OPTIONS, methods, headers, and max-age
The preflight request is an automatic OPTIONS request that the browser sends before the actual request, without any JavaScript code explicitly triggering it. It carries the Access-Control-Request-Method header with the planned HTTP method and optionally Access-Control-Request-Headers with a list of custom headers the actual request will send along. The server must respond with matching Access-Control-Allow-Methods and Access-Control-Allow-Headers headers, or the browser aborts the actual request without any network error even becoming visible on the real call.
To avoid forcing an extra roundtrip on every request, Access-Control-Max-Age lets the server tell the browser how long, in seconds, to cache the preflight result. A value of 7200 lets the browser reuse the result for two hours, which saves noticeable latency in API-heavy single-page applications. Important: the browser caps this value internally, Chromium currently allows a maximum of 7200 seconds per preflight cache entry, regardless of any higher value the server sends.
# Manually replay a preflight request to inspect the server response
curl -i -X OPTIONS https://api.example.com/rest/V1/graphql \
-H "Origin: https://shop.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type, authorization"
# Expected response with a correctly configured allowlist
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, Store
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 7200
Vary: Origin
4. Access-Control-Allow-Origin: wildcard risks and the credentials catch
The header Access-Control-Allow-Origin: * allows any origin to read the response and is often set out of sheer convenience, because it makes every CORS error in the browser console vanish immediately. For a public, unauthenticated API without sensitive data, that is tolerable. But once credentials are involved, the wildcard becomes dangerous: any website could make requests to the API on behalf of a logged-in user and read the responses.
Browsers know this and actively block the combination of Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true: a request with credentials: 'include' fails as soon as the wildcard is set, even if the server intended to allow credentials. This safety brake prevents accidental combinations, but it tempts many developers into a dangerous workaround.
The workaround mistake usually looks like this: instead of setting a fixed origin, the server simply reflects the incoming Origin header from the request back into Access-Control-Allow-Origin. This technically bypasses the browser lock, because the value is no longer a literal wildcard, but it effectively opens the exact same hole as a wildcard with credentials, just invisible in the server logs. This exact pattern is examined more closely in the next section.
5. Reflecting the origin without an allowlist: the most common mistake
Origin reflection means a server takes the Origin header sent with every incoming request and writes it back unchecked into Access-Control-Allow-Origin, instead of validating it against a fixed list of allowed domains. At first glance, this looks like an elegant solution for multi-domain setups, since every origin works automatically, with no configuration effort. In practice it is one of the most common CORS vulnerabilities, because it effectively treats every origin as trustworthy.
Combined with Access-Control-Allow-Credentials: true, origin reflection lets a malicious site at evil-domain.com make authenticated requests to the API on behalf of a logged-in user and read the response, including session data, order history, or personal information. The browser does not prevent this, because the server actively allowed exactly that origin. Automated scanners specifically look for this pattern by sending a random Origin header and checking whether it comes back reflected unchanged.
The difference between reflection and a real allowlist comes down to a single server-side comparison: the incoming origin must be actively checked against a maintained list of allowed values before it is copied into the response header. That one check separates a secure CORS implementation from a vulnerable one.
6. Implementing a server-side allowlist correctly
A secure allowlist consists of a fixed, server-side maintained list of allowed origins, against which every incoming Origin header is compared exactly, including scheme and port. Only on a match is the value mirrored unchanged into Access-Control-Allow-Origin; for every other origin the header is either omitted entirely or returns a defined fallback value without granting credentials. Also important is the response header Vary: Origin, so caches know that the response depends on the Origin header, more on that in the CDN pitfalls section.
In Magento, this logic can be implemented cleanly as a plugin on the webapi response sender, instead of hard-wiring CORS headers into the web server. This has the advantage that the allowlist can be maintained through Magento's admin configuration, rather than triggering a deployment cycle for the nginx configuration every time a new frontend domain is added. The plugin approach also applies consistently to both REST and GraphQL endpoints, since both run through the same webapi foundation.
<?php
declare(strict_types=1);
namespace Mironsoft\Cors\Plugin;
use Magento\Framework\App\Response\Http as HttpResponse;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;
/**
* Sets CORS headers on webapi responses based on a server-side origin allowlist.
*/
class CorsHeaderPlugin
{
/**
* @param RequestInterface $request Current HTTP request
* @param ScopeConfigInterface $scopeConfig Store-scoped configuration reader
*/
public function __construct(
private readonly RequestInterface $request,
private readonly ScopeConfigInterface $scopeConfig,
) {
}
/**
* Adds Access-Control headers only when the incoming Origin matches the allowlist.
*
* @param HttpResponse $subject Webapi response instance
* @param HttpResponse $result Response after core processing
* @return HttpResponse
*/
public function afterSendResponse(HttpResponse $subject, HttpResponse $result): HttpResponse
{
$origin = (string) $this->request->getHeader('Origin');
$allowedOrigins = $this->getAllowedOrigins();
// Never reflect the Origin header blindly, always compare against a fixed list
if ($origin !== '' && in_array($origin, $allowedOrigins, true)) {
$subject->setHeader('Access-Control-Allow-Origin', $origin, true);
$subject->setHeader('Access-Control-Allow-Credentials', 'true', true);
$subject->setHeader('Vary', 'Origin', true);
}
return $result;
}
/**
* Reads the configured origin allowlist from store configuration.
*
* @return string[]
*/
private function getAllowedOrigins(): array
{
$raw = (string) $this->scopeConfig->getValue(
'mironsoft_cors/general/allowed_origins',
ScopeInterface::SCOPE_STORE
);
return array_filter(array_map('trim', explode(',', $raw)));
}
}
7. CORS for Magento GraphQL and REST APIs in a headless setup
In a decoupled frontend in the style of PWA Studio or a self-built headless setup, the storefront runs on its own origin, for example https://shop.example.com, while the Magento instance with its GraphQL and REST endpoints is reachable at https://api.example.com. From the browser's perspective, these are two completely different origins, so every GraphQL query and every REST request from the frontend is fundamentally a cross-origin request and subject to CORS.
For a production setup, the allowlist needs at minimum the frontend domains actually in use: the production domain, possibly a staging domain, and local development origins like http://localhost:3000 only in the development environment, never in production. The GraphQL endpoint additionally needs Access-Control-Allow-Headers for Content-Type, Authorization, and, where relevant, Magento's Store header, since GraphQL clients send these by default.
If the frontend uses cookies for customer sessions instead of pure bearer tokens, the request must also be sent with credentials: 'include', and the server must return Access-Control-Allow-Credentials: true together with a checked, non-reflected origin. Many headless teams now deliberately opt for stateless token authentication, specifically to avoid this combination of cookies and CORS complexity.
<?xml version="1.0"?>
<!-- app/code/Mironsoft/Cors/etc/adminhtml/system.xml -->
<!-- Allowlist as module configuration instead of hardcoding it in the web server -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
<system>
<section id="mironsoft_cors" translate="label" type="text" sortOrder="200"
showInDefault="1" showInWebsite="1" showInStore="1">
<label>CORS Settings</label>
<tab>mironsoft</tab>
<resource>Mironsoft_Cors::config</resource>
<group id="general" translate="label" type="text" sortOrder="10"
showInDefault="1" showInWebsite="1" showInStore="1">
<label>General</label>
<field id="allowed_origins" translate="label comment" type="textarea"
sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Allowed Origins</label>
<comment>Comma-separated list of allowed frontend origins, e.g. https://shop.example.com</comment>
</field>
<field id="max_age" translate="label" type="text"
sortOrder="20" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Preflight Max-Age (seconds)</label>
</field>
</group>
</section>
</system>
</config>
// Headless frontend: GraphQL query with a cookie-based customer session
async function fetchCustomerCart(query, variables) {
const response = await fetch('https://api.example.com/graphql', {
method: 'POST',
credentials: 'include', // sends and accepts cookies cross-origin
headers: {
'Content-Type': 'application/json',
'Store': 'default',
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
// A CORS rejection surfaces here as a network-level failure, not a 4xx status
throw new Error(`GraphQL request failed: ${response.status}`);
}
return response.json();
}
8. Vary: Origin and the caching pitfalls with CDNs and Varnish
As soon as a server sets Access-Control-Allow-Origin dynamically depending on the incoming origin, as a correct allowlist does, the HTTP response itself becomes origin-dependent. A CDN or a Varnish Full Page Cache that knows nothing about this caches the first response under a cache key with no origin reference and then serves it to every subsequent origin, regardless of which Origin header they send. The result: origin B suddenly receives the Access-Control-Allow-Origin header meant for origin A, and in the worst case gains access to data intended for a different domain.
The response header Vary: Origin instructs compliant caches to treat the Origin header as part of the cache key and to keep a separate cache variant for every origin value. Without this header, a dynamic origin allowlist behind a CDN is effectively no longer a secure allowlist, because the wrong response variant can be served. With Varnish, Origin must additionally be explicitly included in the VCL as part of the hash key, since Varnish does not correctly respect the Vary header in every default configuration.
In practice this means: anyone serving CORS headers behind Varnish or a CDN must test the caching strategy and the CORS configuration together, not separately. A simple test with two different allowed origins, followed by inspecting the response headers actually served, reliably surfaces misconfigurations before they turn into a data leak in production.
# nginx: validate the Origin against an allowlist and set Vary correctly
map $http_origin $cors_allowed_origin {
default "";
"https://shop.example.com" $http_origin;
"https://staging.shop.example.com" $http_origin;
}
server {
location /rest/ {
# Never use "*" together with credentials, always reflect a checked value
add_header 'Access-Control-Allow-Origin' $cors_allowed_origin always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, Store' always;
add_header 'Access-Control-Max-Age' 7200 always;
# Tells CDN/proxy caches the response depends on the Origin header
add_header 'Vary' 'Origin' always;
if ($request_method = 'OPTIONS') {
return 204;
}
proxy_pass http://magento_upstream;
}
}
9. CORS configurations compared
The table below sets typical CORS misconfigurations against their secure counterparts. The order is deliberate: each row builds on the previous one, and a single insecure row is enough to undermine the entire configuration.
| Configuration | Insecure / risky | Recommended configuration | Why |
|---|---|---|---|
| Allow-Origin | "*" with credentials | Checked origin from allowlist | Wildcard plus credentials is blocked by browsers, or is an open data leak without credentials |
| Origin validation | Origin reflected unchecked | Exact match against a fixed list | Reflection effectively treats every origin as trustworthy |
| Preflight caching | No Access-Control-Max-Age | Max-Age set sensibly (e.g. 7200) | Reduces preflight roundtrips without losing security |
| Cache header | No Vary: Origin behind CDN/Varnish | Vary: Origin set and included in the cache key | Prevents serving the wrong CORS header to the wrong origin |
| Credentials handling | Allow-Credentials: true for every origin | Credentials only for checked, trusted origins | Limits the attack surface to the frontends actually needed |
In practice, a single insecure row from this table is often enough to render an entire CORS configuration useless: a correct allowlist accomplishes little if Access-Control-Allow-Credentials is set to true for every origin in parallel. Consistently applying all five points reliably closes the most common CORS vulnerabilities.
Mironsoft
CORS audits, API security, and headless hardening for Magento stores
Ready to have your CORS configuration hardened professionally?
We review your Magento APIs' CORS configuration, close off risky wildcard and origin-reflection patterns, and implement a clean, server-side allowlist that keeps working reliably with CDN and Varnish caching.
CORS audit
Analysis of existing Access-Control headers, origin allowlists, and preflight configuration
Headless hardening
Secure CORS configuration for PWA and headless frontends with Magento GraphQL/REST
Cache compatibility
Applying Vary: Origin correctly so Varnish and the CDN never serve the wrong header
10. Summary
Configuring CORS correctly means deliberately and carefully opening the browser's Same-Origin Policy, rather than accidentally disabling it entirely. The wildcard Access-Control-Allow-Origin: * is acceptable for public, unauthenticated endpoints, but becomes dangerous as soon as credentials are involved, which is exactly why browsers actively block that combination. Reflecting the origin without an allowlist technically sidesteps that safety brake, but effectively opens the same hole and remains one of the most common CORS vulnerabilities in practice.
For Magento APIs consumed by a decoupled PWA or headless frontend, a server-side allowlist with an exact origin match, a correctly set Access-Control-Max-Age for preflight caching, and a clean Vary: Origin header behind a CDN and Varnish is the reliable path forward. Testing these building blocks together consistently, rather than in isolation, avoids both blocked frontend requests and silent security holes caused by wrongly cached responses.
Configuring CORS Correctly, the Essentials at a Glance
Same-Origin & CORS
SOP blocks everything by default, CORS deliberately opens individual origins via Access-Control-Allow-Origin.
Wildcard & credentials
Access-Control-Allow-Origin: * with credentials is blocked by browsers, origin reflection bypasses that unsafely.
Preflight & max-age
The OPTIONS request checks method and headers upfront, Access-Control-Max-Age caches the result for up to 7200 seconds.
Allowlist & Vary
A server-side allowlist plus Vary: Origin prevents wrong CORS headers coming out of CDN and Varnish caches.