Why CORS is easy to get wrong
CORS looks like a small configuration detail. A few headers, a few values, and the browser decides whether a cross-origin request can read the response. The problem is that a small mistake can turn a normal API into a tool for stealing data. I have seen applications that were secure in every other way but exposed user data through a permissive CORS policy.
This post is my personal study note on CORS misconfigurations. I am writing it because the topic is often misunderstood. CORS does not stop a request from being sent. It controls whether the browser allows JavaScript on one origin to read the response from another origin. That distinction is the key to understanding the attack.
The same-origin policy
The browser enforces a same-origin policy. A script running on attacker.com cannot read the response of a request to victim.com unless the server explicitly allows it. The policy prevents one website from stealing data from another.
CORS is the exception mechanism. The server can include an Access-Control-Allow-Origin header that tells the browser which origins are allowed to read the response. If the header matches the origin of the requesting page, the browser lets the script read the response.
The important part is that the request is still sent. The browser sends the request, and the server processes it. CORS only decides whether the response is readable by the script. This means a CORS misconfiguration can leak data even when the server is otherwise secure.
The header that matters
The most important header is Access-Control-Allow-Origin. It can contain a single origin, a wildcard, or a list of origins. The browser compares this header with the Origin header of the request. If they match, the response is readable.
The second important header is Access-Control-Allow-Credentials. If it is set to true, the browser sends cookies with the cross-origin request. This is the setting that makes CORS attacks dangerous, because the victim's session is included in the request.
The combination of a permissive origin and credentials is the most dangerous pattern. If the server reflects any origin and allows credentials, an attacker can create a page that reads the victim's data from the API.
Reflected origins
The most common misconfiguration is reflecting the Origin header. The server takes the value of the Origin header and returns it in Access-Control-Allow-Origin. The browser sees that the origin matches and allows the response to be read.
The reflection works for any origin. An attacker can send a request with Origin: https://attacker.com, and the server returns Access-Control-Allow-Origin: https://attacker.com. The attacker's page can then read the response.
Reflected origins are often implemented during development to make testing easier. The developer adds a small piece of code that echoes the origin, forgets to remove it, and the behavior reaches production. The fix is an explicit allowlist.
Wildcard origins and credentials
The wildcard value * is a common source of confusion. It allows any origin to read the response. When credentials are not included, the wildcard is not a major issue for authenticated data. The browser will not send cookies, so the request is anonymous.
The problem appears when the server also sets Access-Control-Allow-Credentials: true. The browser specification says the wildcard cannot be combined with credentials. Some servers still return both headers, and browsers reject the response. Other servers handle the situation inconsistently, which makes testing important.
I always test the wildcard with and without credentials. The behavior can change depending on the browser, the request method, and the endpoint. The safest design is to avoid the wildcard for authenticated endpoints.
The null origin
The null origin is a special value that appears in certain contexts. A sandboxed iframe, a local file, or a redirect can send Origin: null. Some servers include null in their allowlist or reflect it back.
If the server allows null origins and credentials, an attacker can use a sandboxed iframe to send a request with a null origin. The browser treats the request as cross-origin and allows the response to be read if the CORS headers match.
The null origin is easy to overlook because it does not look like a normal domain. I test it with a sandboxed iframe or with a request that produces a null origin. If the server allows it, the finding is valid.
Subdomain trust
Some applications use an allowlist that includes a domain and all of its subdomains. The intent is to allow internal applications to share data. The problem is that a subdomain can be compromised or controlled by an attacker.
If the allowlist includes *.example.com, and an attacker controls evil.example.com through a subdomain takeover or an XSS, the attacker can read data from the API. The CORS policy is only as strong as the weakest origin in the allowlist.
I test the allowlist by sending requests with origins that are similar to the allowed domain. A server that uses a substring match may accept notexample.com, example.com.attacker.com, or example.com.evil.com. The exact match should be the only accepted form.
Preflight requests
Some requests trigger a preflight. The browser sends an OPTIONS request with an Access-Control-Request-Method header and an Access-Control-Request-Headers header. The server responds with the allowed methods and headers.
The preflight response is part of the CORS policy. If the server does not include the correct headers, the browser blocks the actual request. I test the preflight for the methods and headers that the target API uses.
The preflight can also reveal information. A server that handles OPTIONS differently from GET or POST may have inconsistent CORS policies. I compare the responses and use the differences in the test.
Building an exploitation page
The exploitation page is simple. It contains a script that sends a request to the vulnerable API with credentials: 'include'. The script then reads the response and sends the data to the attacker's server.
The page must be hosted on an origin that is allowed by the CORS policy. If the policy reflects any origin, any hosting location works. If the policy allows a specific origin, I need to control that origin.
The response may contain sensitive data such as profile information, transaction history, or session tokens. I document the data that can be read and the impact. The finding is only a proof of concept, not a real attack against a real user.
Fixing CORS
The fix is an explicit allowlist. The server should compare the Origin header with a list of exact origins and return only a matching value. The list should not include wildcards, null, or partial domains.
Credentials should only be enabled for origins that need them. If an endpoint does not require cookies, the server should not set Access-Control-Allow-Credentials. The combination of a reflected origin and credentials should never happen.
The Vary: Origin header is also important. It tells the browser and any caching layer that the response depends on the Origin header. Without it, a cached response for one origin can be served to another origin.
Which endpoints deserve the most attention
Not every CORS misconfiguration has the same impact. An endpoint that returns public data is less interesting than an endpoint that returns user profiles, transaction history, or session tokens. I prioritize the endpoints that handle sensitive data.
The most sensitive endpoints are the ones that return data based on the authenticated session. If the server reflects any origin and allows credentials, the attacker can read the victim's data. I test these endpoints first.
I also test the endpoints that return tokens. A response that contains an access token, a CSRF token, or a session identifier is valuable to an attacker. The CORS policy on these endpoints should be the most restrictive.
The less sensitive endpoints can still be a finding, but the severity is lower. I report them with a clear description of the data exposed.
CORS in practice
The CORS configuration is often spread across multiple layers. The web server, the reverse proxy, the API gateway, and the application framework can each add or modify the CORS headers. I need to understand which layer controls the final response.
The configuration can also be duplicated. A header set by the application may be overridden by the proxy, or the proxy may add a second header. I inspect the final response that the browser receives, not the configuration file.
The duplication can create a false sense of security. An application that sets a strict allowlist may still be vulnerable if the proxy adds a wildcard. I test the response headers directly.
The headers can also be added conditionally. Some servers only add CORS headers when the request has an Origin header. I test with and without the Origin header and compare the responses.
Caching and the Vary header
The Vary header is a detail that many people miss. It tells the browser and the caching layer that the response depends on a request header. Without Vary: Origin, a cached response for one origin can be served to another origin.
The scenario is simple. User A requests the API with Origin A, and the response is cached. User B requests the same URL with Origin B, and the cache returns the response that was created for Origin A. If the response contains user A's data, user B receives it.
I test the caching behavior by sending the same URL with different Origin headers and comparing the response. If the server returns the same cached response, the configuration is vulnerable.
The fix is to add Vary: Origin to every response that depends on the Origin header. The cache should be configured to include the Origin in the cache key.
Testing with a browser
The raw HTTP request is only part of the test. The browser enforces the CORS policy, and the enforcement can differ from the server's response. I test the exploitation page in a real browser to confirm the behavior.
The browser console shows the CORS error when the policy blocks the response. If the policy allows the response, the script can read the data. I use the console and the network tab to verify the result.
The browser also sends the Origin header automatically. I do not need to add it manually when I test from a page on a different origin. The origin is part of the page's context.
The browser test confirms that the finding is real. A server that returns the correct headers on paper may still fail in the browser because of an additional header or a redirect. The browser is the final judge.
Common mistakes I make
My biggest mistake is testing only the default origin. The browser sends an Origin header, and I do not always notice it. I need to send a custom Origin and compare the response. My second mistake is ignoring the credentials flag. The same CORS policy is much more dangerous with credentials.
The third mistake is treating the wildcard as safe when the endpoint returns sensitive data. The wildcard may not allow credentials, but it still allows anonymous data to be read. The fourth mistake is forgetting the preflight request.
My quick checklist
- Capture the normal response and note the CORS headers.
- Send a custom Origin and check whether it is reflected.
- Test the wildcard with and without credentials.
- Test the null origin and sandboxed iframes.
- Test subdomain and partial-match allowlists.
- Test the preflight request for methods and headers.
- Build a proof-of-concept page and document the data exposed.
- Verify the fix includes an exact allowlist and Vary: Origin.
What I would do next time
Next time I want to test CORS on every endpoint that returns sensitive data, not just the main API. The policy can be different for different routes. I also want to spend more time on the preflight behavior, because that is where inconsistent policies hide. CORS is not a checkbox. It is a policy that needs to be tested exactly the way the browser enforces it.