The Access-Control-Allow-Origin value is not a valid origin
What this check looks for
The header is there but its value is not something a browser can use — several origins at once, a trailing slash, a path, or a hostname with no https:// in front. Browsers reject it and the cross-origin request fails.
Why it matters
This is a broken integration rather than an exposure: nothing extra is being shared, the sharing you configured simply does not happen. The symptom is a fetch that fails only in the browser while curl works perfectly, which is why these can sit unnoticed for a long time.
When the check passes, your report says: “Browsers can use the Access-Control-Allow-Origin value as sent”.
What it costs your score
When this check fails it removes 6 points from your Web security score, before the status, confidence and repeat multipliers are applied. Web security carries a weight of 8 in the overall score.
It shares the web-security.cors family ceiling of 35 points: however many findings that family produces, together they cannot remove more than that from Web security. One underlying problem showing up in several places is still one problem.
- Severity
- medium
- Default confidence
- confirmed
- Status when triggered
- warn
- Deduction
- 6 points
- Family cap
- web-security.cors · 35
- Category
- Web security
- Module
- Web cors
- Fix owned by
- user
- In the ruleset since
- 2026.09
How to fix it
Emit exactly one valid origin — scheme, host and port only, with no trailing slash.
A value the browser cannot parse blocks the request, so the sharing you configured never happens.
Reduce the value to a single origin:
https://app.example.com, orhttps://app.example.com:8443when the port is non-default.Remove any trailing slash, path or query string — an origin has none of those.
Include the scheme;
app.example.comis not an origin andhttp://is a different one fromhttps://.To support more than one origin, keep the full list server-side, compare the request's
Originagainst it with an exact equality test, and emit only the matched value.Add
Vary: Originonce the value depends on the request, so caches key on it.Before deploying the corrected value, confirm the origin really should be allowed — this fix turns access on rather than off.
How to confirm it worked
curl -sSI -H 'Origin: https://app.example.com' https://‹host›/ | grep -i '^access-control-allow-origin:' — expect one origin, no comma, no trailing slash
Access-Control-Allow-Origin: https://app.example.com
Vary: OriginA named slot like ‹domain› — and the braces left in the configuration below — is filled in with your own values when this rule appears on a report.
Remediation by platform
# http scope. The map is the allow-list: an origin that is not a key here gets "".
map $http_origin $cors_origin {
default "";
"https://app.example.com" "https://app.example.com";
"https://admin.example.com" "https://admin.example.com";
}
# Credentials are granted only when the origin matched.
map $cors_origin $cors_credentials {
default "";
~. "true";
}
server {
location /api/ {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials $cors_credentials always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
add_header Access-Control-Max-Age "600" always;
add_header Vary "Origin" always;
if ($request_method = OPTIONS) {
return 204;
}
}
}<IfModule mod_headers.c>
# The capture group is the allow-list: only these exact origins set the variable.
SetEnvIf Origin "^(https://app\.example\.com)$" CORS_ORIGIN=$1
SetEnvIf Origin "^(https://admin\.example\.com)$" CORS_ORIGIN=$1
Header always set Access-Control-Allow-Origin "%{CORS_ORIGIN}e" env=CORS_ORIGIN
Header always set Access-Control-Allow-Credentials "true" env=CORS_ORIGIN
Header always set Access-Control-Allow-Methods "GET, POST, OPTIONS" env=CORS_ORIGIN
Header always set Access-Control-Allow-Headers "Authorization, Content-Type" env=CORS_ORIGIN
Header always set Access-Control-Max-Age "600" env=CORS_ORIGIN
# Unconditional: the response varies by Origin whether or not this one matched.
Header always append Vary Origin
</IfModule>@allowed_origin header Origin https://app.example.com
header @allowed_origin {
Access-Control-Allow-Origin "https://app.example.com"
Access-Control-Allow-Credentials "true"
Access-Control-Allow-Methods "GET, POST, OPTIONS"
Access-Control-Allow-Headers "Authorization, Content-Type"
Access-Control-Max-Age "600"
}
# Unconditional, so caches key on Origin even for the unmatched response.
header Vary Origin
@preflight method OPTIONS
respond @preflight 204const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com',
]);
app.use((req, res, next) => {
// Set unconditionally: the answer depends on Origin even when it is refused.
res.vary('Origin');
const origin = req.headers.origin;
if (origin !== undefined && ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
res.setHeader('Access-Control-Max-Age', '600');
}
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});Rules → Overview → Create rule → Response Header Transform Rule (path as at 2026-09), with an expression that tests the request Origin against a fixed list, e.g.
http.request.headers["origin"][0] in {"https://app.example.com" "https://admin.example.com"}
and a matching rule that always sets Vary: Origin.The
maphas to be at http scope — nginx has no allow-list construct insidelocation, and building one out ofifthere is the source of most broken nginx CORS configurations. nginx skips anadd_headerwhose variable is empty, so an unmatched origin simply receives no CORS headers.alwaysis required or the headers vanish on error responses and on the 204 preflight. Remember that anyadd_headerinside alocationdiscards everyadd_headerinherited from the server block, so keep the whole set together.Shown because the response identified Apache.
SetEnvIfplusenv=is the mechanism —Header sethas no conditional form of its own, and anchoring the regex with^…$is what stopshttps://app.example.com.attacker.examplematching. Put it in the virtual host: .htaccess runs too late for responses the server generates itself, including the preflight.The
header Origin <value>matcher compares the whole value, so it is an exact-match allow-list rather than a prefix test. Repeat the matcher and block for each additional origin.The origin is written back only after the
Setmembership test, which is the difference between an allow-list and a reflection. Never build the set from a request value, an environment variable that is not reviewed, or a substring/endsWithtest —https://app.example.com.attacker.exampleends with nothing useful but starts with everything an attacker needs.Treat this as a sketch of the approach rather than a copy-paste: the exact expression and the dynamic-value syntax differ between Cloudflare plans, and a static-value rule can only serve one origin. A Transform Rule also runs at Cloudflare's edge, so an origin that is reachable directly — by IP, or through a DNS record that is not proxied — still serves the policy measured here. Setting the allow-list at the origin covers both paths and is what we recommend.
Technical detail
Access-Control-Allow-Origin: ‹allow origin›. ‹detail›
The header takes exactly one value: a single serialised origin, or the literal *. It is not a list. The four shapes we see are:
- **Two or more origins in one header** —
https://a.example, https://b.example. There is no list syntax; pick one per request from your allow-list. - **A trailing slash** —
https://app.example.com/. An origin has no path component, and the empty path is a path. - **A path or query** —
https://app.example.com/api. Same reason; CORS grants are per-origin, never per-path. - **A bare host** —
app.example.com. The scheme is part of the origin, andhttp://andhttps://are different origins.
Because the value fails to parse as an origin, browsers treat the CORS check as failed and block the response — the same outcome as sending no header at all. Fixing the syntax will make cross-origin access start working, so confirm the origin on the list is one you actually intend to allow before you correct it.
Detected server: ‹detected server›.
Standards and references
Test this on your domain
Run the check that produces this finding, on its own, against any domain.
Other web cors checks
- The CORS policy accepts any request header from any origin
- The CORS policy grants broad methods on an unrestricted origin
- The null origin is on the CORS allow-list
- Any website is allowed to read this site's responses
- Any website can read authenticated responses from this site
- Preflight results are not cached
- The CORS response varies by origin but is not marked Vary: Origin
- Resources are shared with every origin, without credentials
- The CORS policy pairs a wildcard origin with credentials