dotvitals

The CORS policy accepts any request header from any origin

LowConfirmedweb.cors.headers-overly-broad

What this check looks for

The preflight said another site may send any header it likes with its request — on a policy that does not restrict which sites may ask. That includes headers your application treats as meaningful.

Why it matters

Applications routinely trust headers: an API key header, an X-Forwarded-* value, a tenant or impersonation header, a header a proxy is assumed to be the only source of. A wildcard grant on an unrestricted origin lets another site set all of them from the visitor's browser.

When the check passes, your report says: “The request headers accepted cross-origin are a named list”.

What it costs your score

When this check fails it removes 4 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
low
Default confidence
confirmed
Status when triggered
warn
Deduction
4 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

Fix the origin policy, then name the specific request headers the endpoint expects.

A wildcard header grant on an origin policy that allows everyone lets any site set headers your application trusts.

  1. Replace the wildcard or reflected origin with a fixed allow-list compared by exact equality, returning the matched origin and Vary: Origin.

  2. Replace * with the headers this route actually reads, for example Authorization, Content-Type.

  3. Name Authorization explicitly if the endpoint uses it — * does not cover it, and it is ignored entirely for credentialed requests.

  4. Audit any header the application treats as trusted, such as X-Forwarded-For or a tenant identifier, and make sure it is stripped at the edge rather than accepted from the client.

How to confirm it worked

  • curl -sSI -X OPTIONS -H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: GET' -H 'Access-Control-Request-Headers: authorization' https://‹host›/ | grep -i '^access-control-allow-headers:'

The configuration to publish
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Headers: Authorization, Content-Type
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-Headers: ‹headers› on a policy whose Access-Control-Allow-Origin is a wildcard or reflected. ‹detail›

As with the method grant, this is only reported in combination with an origin policy that does not narrow anything. Access-Control-Allow-Headers: * against a fixed allow-list of your own front ends is unremarkable.

One honest correction to the usual reading of *: it does **not** cover Authorization. The Fetch Standard excludes it from the wildcard specifically, so a preflight that answers * still fails for a request carrying an Authorization header — you have to name Authorization explicitly. That cuts both ways here. It means a wildcard is not quite as broad as it looks, and it means a policy relying on * for an authenticated API is quietly broken. * is also ignored altogether for credentialed requests, where every header must be named.

A header allow-list is not a security boundary in itself; it is a statement of what the endpoint expects. Treat any header your application trusts as attacker-controlled regardless of what this policy says, and fix the origin policy as the actual remediation. 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