The CORS policy grants broad methods on an unrestricted origin
What this check looks for
The preflight said which HTTP methods another site may use, and the list includes ones that change or delete data — on a policy that does not restrict which sites may ask.
Why it matters
On its own a method grant is not access, because the browser still enforces the origin rule. It becomes access the moment the origin rule is the problem — and here it is, because the allowed origin is a wildcard or an echo. Together they let another site issue writes as your visitor.
When the check passes, your report says: “The methods granted cross-origin match the policy's reach”.
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 to fix it
Fix the origin policy first, then name only the methods the endpoint actually serves.
A broad method grant on an origin policy that allows everyone is what makes cross-origin writes possible.
Replace the wildcard or reflected origin with a fixed allow-list compared by exact equality, returning the matched origin and
Vary: Origin.Replace
*with the explicit methods this route serves — and note that*is ignored entirely for credentialed requests, so it has to be explicit there in any case.Drop
PUT,PATCHandDELETEfrom any route that does not implement them.Keep server-side authorisation and CSRF protection in place regardless: a simple cross-origin
POSTis never preflighted, so this header does not gate it.
How to confirm it worked
curl -sSI -X OPTIONS -H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: DELETE' https://‹host›/ | grep -i '^access-control-allow-methods:'
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
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-Methods: ‹methods› on a policy whose Access-Control-Allow-Origin is a wildcard or reflected. ‹detail›
This rule is deliberately conditional. Access-Control-Allow-Methods: PUT, DELETE against a tight allow-list of your own origins is an ordinary API configuration and is not reported. What is reported is a broad method grant sitting on top of an origin policy that does not narrow anything, because that combination is what turns a read exposure into a write one.
Two details worth knowing: * is a valid value here, but it is ignored for credentialed requests — for those every method must be named explicitly, so a policy that relies on * and also sets credentials is not granting what it looks like. And CORS is not a substitute for CSRF protection: simple POST requests with a form-encoded body never trigger a preflight at all, so removing POST from this list does not stop them.
Fix the origin first. Once the allow-list is fixed the method grant is scoped to origins you chose, and narrowing it further is tidying rather than 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.
Other web cors checks
- The CORS policy accepts any request header from any origin
- The Access-Control-Allow-Origin value is not a valid 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