Skip to main content
All articles

No 'Access-Control-Allow-Origin' header is present on the requested resource: the error is right, and both popular fixes are vulnerabilities

No 'Access-Control-Allow-Origin' header is present on the requested resource. The two fixes developers copy do not fix CORS, they delete it. One is account takeover.

11 min read0 views

No 'Access-Control-Allow-Origin' header is present on the requested resource is not a bug report. It is the browser saying it enforced a boundary correctly and your server declined to waive it.

The full message has this shape:

Access to fetch at 'https://api.example.com/v1/me' from origin 'https://app.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

Two fixes dominate the answers: Access-Control-Allow-Origin: *, and reflecting the request's Origin header back while also setting Access-Control-Allow-Credentials: true. Neither fixes CORS. The first removes it; the second removes it and hands every website your user visits a read primitive against your authenticated API.

Same-origin policy is the rule. CORS is the exception.

The same-origin policy is why a page on evil.example cannot read your Gmail. MDN states the mechanic plainly: a web application using fetch() or XMLHttpRequest "can only request resources from the same origin the application was loaded from unless the response from other origins includes the right CORS headers."

Read the second half of that sentence. CORS is not a wall; it is the documented procedure for taking the wall down, selectively, at the server's request. Every CORS header you add is a relaxation. No configuration of CORS makes an API more protected than adding none at all.

Which gives us the misconception to destroy first: CORS is a browser control, not a server-side authorization control. The check runs in the user's browser, after your server ran the query, serialised the JSON and put it on the wire; the browser simply declined to hand the bytes to the calling JavaScript. So curl, a mobile app and an attacker with a shell are unaffected by your policy, because none of them implement the same-origin policy. "We rely on CORS" means your API is protected against exactly one attacker: a well-behaved browser running someone else's JavaScript.

Why * is not a fix

Access-Control-Allow-Origin: * tells every browser that scripts from any origin may read this response. For genuinely public data that is correct. For anything user-specific it is wrong, and developers usually discover this by accident, because the wildcard stops working the moment they send cookies.

The Fetch Standard's CORS check is short enough to quote in full:

Let origin be the result of getting Access-Control-Allow-Origin from response's header list. If origin is null, then return failure. If request's credentials mode is not "include" and origin is *, then return success. If the result of byte-serializing a request origin with request is not origin, then return failure. If request's credentials mode is not "include", then return success. Let credentials be the result of getting Access-Control-Allow-Credentials from response's header list. If credentials is true, then return success. Return failure.

Step three is the wildcard's entire privilege, and it is gated on credentials mode not being "include". Once credentials are in play, execution falls through to step four, which byte-compares the header value against the serialised request origin. The literal * does not equal https://app.example.com, so the check fails. MDN says the same in prose: the server "must not specify the * wildcard for the Access-Control-Allow-Origin response-header value, but must instead specify an explicit origin". The same prohibition covers Access-Control-Allow-Headers, Access-Control-Allow-Methods and Access-Control-Expose-Headers on credentialed requests.

This is the one place CORS is load-bearing. * plus credentials would mean "any origin may read this user's authenticated data", which nobody writes deliberately, so the spec forbids expressing it in one header.

Developers then express it in two.

Origin reflection: the fix that is the vulnerability

The wildcard failed, an explicit origin is required, and nobody knows at deploy time which origins will call the API. So the middleware gets written:

// Every line of this is a vulnerability.
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');

The error disappears and the feature works. Nothing complains, because from the browser's point of view nothing is wrong: the server explicitly authorised whatever origin asked.

That is the bug. An allowlist that returns true for every input is not an allowlist. This is the *-plus-credentials combination the spec refuses, reassembled one request at a time, and PortSwigger's description is the right one: "absolutely any domain can access resources from the vulnerable domain."

What it buys an attacker, conceptually: a page under their control, loaded in your user's browser, issues a credentialed cross-origin request. The session cookie rides along, your API authenticates it as the user and reflects the attacker's origin, so the browser hands the body to the attacker's JavaScript. Session tokens, API keys, CSRF tokens, PII — anything a GET under that session authorises. Stored-XSS impact, without XSS anywhere on your property.

Two qualifications, because the severity turns on them.

Cookies have to actually be sent. Chromium treats a cookie with no SameSite attribute "as if they were SameSite=Lax" since Chrome 80, and Lax excludes fetch() and subresource requests. So a cookie set without SameSite will not arrive on an attacker's cross-site fetch(). One set SameSite=None; Secure will, and plenty of APIs need exactly that.

SameSite is site-scoped; CORS is origin-scoped. This is where the qualification stops being reassuring. SameSite compares registrable domains, so app.example.com and attacker.example.com are the same site and cookies flow between them, while being different origins, so CORS is fully engaged. A reflecting API plus a foothold on any subdomain — a forgotten staging host, a CNAME to a dead SaaS provider — is a working credentialed read. Lax-by-default buys you a lot against evil.example and nothing against old-blog.example.com.

What an attacker can and cannot read

ReachableNot reachable
The response body of any request the misconfiguration authorisesThe cookie value — HttpOnly applies; the cookie is used, not read
The CORS-safelisted response headers: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, PragmaAny other header, unless you named it in Access-Control-Expose-Headers
Responses to state-changing requests, if the preflight also passesCredentials the attacker's page cannot make the browser attach, such as a bearer token in your app's memory

The middle row is the one to check in review: teams that expose Authorization, X-CSRF-Token or a custom session header through Access-Control-Expose-Headers turn a bad read into a persistent one.

Allowlists that are not allowlists

Most real findings are not naked reflection. They are an allowlist with a matching bug, and it is usually one of three.

Suffix and prefix matching. origin.endsWith('example.com') accepts https://notexample.com and https://example.com.evil.example; origin.startsWith('https://app.example.com') accepts https://app.example.com.evil.example. The attacker registers a domain that begins or ends with your string — PortSwigger documents this as the standard bypass of whitelists validated by prefix or suffix.

Unescaped dots in a regex. /^https:\/\/app.example\.com$/ looks strict and is not: the unescaped . after app matches any character, so https://appxexample.com passes. It survives review because the regex is 90% correct.

Allowlisting null. Developers add null because they saw Origin: null in a log and read it as "no origin, therefore local, therefore safe". MDN is unambiguous: the value null "should not be used", because "any origin can create a hostile document with a null origin". The cases MDN lists as serialising to null include data: URLs, cross-origin redirects, and iframes with a sandbox attribute whose value does not include allow-same-origin. That last one is the practical route: a sandboxed iframe is embeddable in any page, so Origin: null is attacker-selectable, not a signal of locality.

Reflection is also cacheable: a proxy holding a response whose Access-Control-Allow-Origin was computed from the request can serve one origin's allowance to another. Hence MDN's rule that a server specifying a single origin "rather than the * wildcard" should "also include Origin in the Vary response header".

Preflights, and which requests skip them

Response to preflight request doesn't pass access control check is the other error string. It comes from an earlier stage: the browser first sends an OPTIONS request carrying Access-Control-Request-Method and Access-Control-Request-Headers, and will not proceed unless the response authorises them. For credentialed requests, Access-Control-Allow-Credentials: true must be on that preflight response too, as the literal, case-sensitive true, or the browser reports a network error.

Two things engineers get wrong here. The first is assuming every cross-origin request has a preflight. Simple requests do not. Per the Fetch Standard and MDN, a request skips preflight when the method is GET, HEAD or POST, the headers stay within the CORS-safelisted set (Accept, Accept-Language, Content-Language, Content-Type, and Range with a single range value), and any Content-Type present has an essence of application/x-www-form-urlencoded, multipart/form-data or text/plain. So a cross-origin POST with Content-Type: text/plain reaches your handler with no preflight at all. The response may be blocked from the caller's script, but the side effect already happened. That is the CSRF shape, and CORS was never the control for it.

The second is treating the preflight as a gate. It is a question the browser asks on behalf of a script it is willing to restrain. Nothing outside a browser asks it at all.

Check your own endpoint

Run this against your own API. Note what it proves on the way past: curl gets the body every time, whatever Origin you send, because curl does not implement the same-origin policy.

One request, headers only:

curl -s -o /dev/null -D - https://api.example.com/v1/me \
  -H 'Origin: https://evil.example' \
  | tr -d '\r' \
  | grep -iE '^(access-control-|vary):'

A safe endpoint prints nothing, or an origin that is not the one you sent. A reflecting endpoint prints your origin back, and the access-control-allow-credentials line is what turns a smell into a finding:

# safe
access-control-allow-origin: https://app.example.com
vary: Origin

# vulnerable
access-control-allow-origin: https://evil.example
access-control-allow-credentials: true

Then sweep the matching bugs in one pass. Each origin targets one mistake from the previous section:

TARGET='https://api.example.com/v1/me'

for o in \
  'https://evil.example' \
  'null' \
  'https://app.example.com.evil.example' \
  'https://notapp.example.com' \
  'https://appxexample.com' \
  'http://app.example.com' \
  'https://app.example.com' ; do
  printf '%-40s ' "$o"
  curl -s -o /dev/null -D - "$TARGET" -H "Origin: $o" \
    | tr -d '\r' \
    | grep -iE '^access-control-allow-(origin|credentials):' \
    | paste -sd' ' - \
    | grep . || echo '(no CORS headers)'
done

How to read the output:

Output for an origin you inventedVerdict
(no CORS headers)Fine. The browser blocks the read.
Your origin echoed back, no credentials headerMisconfiguration. Public data is exposed to any origin's script; user data is not, since the CORS check fails at step four without true.
Your origin echoed back and access-control-allow-credentials: trueVulnerability. Fix this before anything else.
A fixed, correct origin whatever you sendCorrect. Confirm Vary: Origin is present too.

Check the preflight path separately; plenty of stacks apply CORS middleware only to OPTIONS, or only to the real method:

curl -s -o /dev/null -D - -X OPTIONS https://api.example.com/v1/me \
  -H 'Origin: https://evil.example' \
  -H 'Access-Control-Request-Method: DELETE' \
  -H 'Access-Control-Request-Headers: authorization,content-type' \
  | tr -d '\r' \
  | grep -iE '^(access-control-|vary):'

Run it unauthenticated first: most frameworks set these headers in middleware that runs before authentication, so you can find the bug without a session. Then run it with a session cookie, because some stacks set CORS headers only on authenticated routes. Then run it against every origin your API answers on, including the load balancer hostname and any legacy domain — that last category is its own problem, covered in shadow APIs.

The fix

Keep an allowlist on the server and compare exact strings. Not a suffix, not a prefix, not a regex. A set of complete origin strings — scheme, host, any non-default port — from configuration. Look the request's Origin up in it; if it is there, echo that exact value; if not, send no CORS headers and let the browser produce the error this post is named after. Never add null.

Send Vary: Origin whenever the value depends on the request. One line, and it stops a cache serving one origin's allowance to another.

Set Access-Control-Allow-Credentials: true only on routes that genuinely need cookies. It is the difference between an information exposure and an account-takeover primitive, so treat it as the trigger for a second pair of eyes on the allowlist above.

Then do the part that actually protects the API. Every endpoint verifies, on the server, on every request, that the authenticated principal is entitled to the specific object and action in front of it, with no reference to where the call came from. Origin is attacker-controlled data. So is Referer. Neither is an authorization input.

That is the same conclusion as BOLA, BFLA and BOPLA, and not by coincidence. Broken object-level authorization, shadow endpoints and CORS misconfiguration are one root cause in three hats: authorization decided somewhere other than the endpoint. In BOLA, by the object ID the client sent. In CORS, by a header the client sent, evaluated by software the client controls.

Take this away

No 'Access-Control-Allow-Origin' header is present on the requested resource means the same-origin policy worked. The question it asks is not "how do I silence this" but "which origins, exactly, should read this response with this user's credentials". Usually that list has one entry; sometimes it is empty and the frontend should be calling a same-origin path instead.

Whatever goes in it, assume the attacker is not using a browser. Run the loop above and see what your middleware says to an origin you just invented.

Sources

Was this useful?

Share

Tags

  • web security
  • api security
  • authorization
  • secure code review
  • penetration testing

Comments

Loading comments…

Leave a comment

Comments are read and approved by hand before they appear, so yours will not show up straight away. Your email address is optional, is never published, and is only used if we need to reply to you directly.

0/5000

Related service

API Penetration Testing

Authorisation, object-level access and abuse testing against your API surface.

If you want to know whether what you have just read applies to your own systems, that is the engagement that answers it.