dotvitals

The Access-Control-Allow-Origin value is not a valid origin

MediumConfirmedQuick winweb.cors.malformed-allow-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 the whole score is calculated

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.

  1. Reduce the value to a single origin: https://app.example.com, or https://app.example.com:8443 when the port is non-default.

  2. Remove any trailing slash, path or query string — an origin has none of those.

  3. Include the scheme; app.example.com is not an origin and http:// is a different one from https://.

  4. To support more than one origin, keep the full list server-side, compare the request's Origin against it with an exact equality test, and emit only the matched value.

  5. Add Vary: Origin once the value depends on the request, so caches key on it.

  6. 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

The configuration to publish
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin

A 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

nginx
# 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;
		}
	}
}
Apache
<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>
Caddy
@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 204
Express
const 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();
});
Cloudflare
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 map has to be at http scope — nginx has no allow-list construct inside location, and building one out of if there is the source of most broken nginx CORS configurations. nginx skips an add_header whose variable is empty, so an unmatched origin simply receives no CORS headers. always is required or the headers vanish on error responses and on the 204 preflight. Remember that any add_header inside a location discards every add_header inherited from the server block, so keep the whole set together.

  • Shown because the response identified Apache. SetEnvIf plus env= is the mechanism — Header set has no conditional form of its own, and anchoring the regex with ^…$ is what stops https://app.example.com.attacker.example matching. 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 Set membership 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/endsWith test — https://app.example.com.attacker.example ends 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, and http:// and https:// 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.

Open the web cors checkerBuild the fix

Other web cors checks