June 29, 2026
Bypassing Enterprise SSO via a Forgotten Source Map: A Bug Bounty Story
When you look at a bug bounty scope with 15+ root domains, it’s easy to get overwhelmed. Most hunters will fire off their automated…

By Priyansh
5 min read
When you look at a bug bounty scope with 15+ root domains, it's easy to get overwhelmed. Most hunters will fire off their automated scanners, look for low-hanging fruit like missing security headers, and move on. But sometimes, the most critical vulnerabilities are hiding in the architecture of a Single Page Application (SPA) that looks completely impenetrable at first glance.
Today, I want to share a story of how I found a critical authentication bypass on an enterprise application. We'll go step-by-step through the recon, the dead ends, the JavaScript deep dive, and the exact moment when a single forgotten developer file exposed a fatal flaw in the application's real-time communication layer.
Let's get into it.
Phase 1: The Recon and the "Boring" Target
I started my recon like any other engagement: running subfinder to enumerate subdomains and piping the results into httpx to grab HTTP status codes, titles, and tech stacks.
Most of the scope returned standard CMS installations, API gateways returning 401 Unauthorized, or redirect loops. But one subdomain caught my eye. Let's call it teams-app.example.com.
When I hit the root path, I got a 200 OK with a completely blank HTML body:
<!doctype html>
<html lang="de"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/>
<meta name="description" content="Enterprise Teams App"/><title>Enterprise App</title><script defer="defer" src="/static/js/main.abc123.js">
</script><link href="/static/css/main.def456.css" rel="stylesheet">
</head><body><noscript>Sie benötigen JavaScript um diese Anwendung nutzen zu können.</noscript>
<div id="root"></div></body></html><!doctype html>
<html lang="de"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/>
<meta name="description" content="Enterprise Teams App"/><title>Enterprise App</title><script defer="defer" src="/static/js/main.abc123.js">
</script><link href="/static/css/main.def456.css" rel="stylesheet">
</head><body><noscript>Sie benötigen JavaScript um diese Anwendung nutzen zu können.</noscript>
<div id="root"></div></body></html>This was a React Single Page Application. It was embedded inside Microsoft Teams (hence the name). SPAs are notoriously difficult to test because all the routing and business logic happen client-side, and the backend API is often hidden behind a reverse proxy that returns the index.html file for any unknown path (a catch-all route).
I tried probing common API paths like /api/v1/users, /api/docs, and /swagger-ui.html. Every single request returned a 200 OK with text/html. The Nginx reverse proxy was aggressively swallowing all requests and serving the SPA HTML wrapper. I was effectively blind to the backend.
Phase 2: The JavaScript Grind & The Hidden Config
If I couldn't find the API through brute force, I had to read the application's mind. I downloaded the main JavaScript bundle: main.abc123.js.
It was heavily minified. I ran my standard grep commands looking for absolute URLs (https://, /api/), API keys, or AWS tokens. Nothing. The developers had done a good job keeping secrets out of the main bundle.
But then I checked for a Source Map file. Developers use source maps to map minified code back to its original, readable state for debugging purposes. If they forget to disable this in production, it's a goldmine.
curl -s -o /dev/null -w "%{http_code}" https://teams-app.example.com/static/js/main.abc123.js.map 200 OK
The server replied:
{ "apiUrl": "https://func-backend-live-api.azurewebsites.net", "memberUrl": "https://func-backend-live-member.azurewebsites.net" }
Not only had I found the backend API host (an Azure Function App), but I also confirmed that the frontend was talking directly to Azure infrastructure.
Phase 3: The SignalR Discovery
Now that I had the backend URL, I started probing the Azure Function endpoints directly. I tried /api/Users, /api/Config, /api/GetVotes. Every single one returned a 404 Not Found with an empty body.
Why were they 404ing? The frontend clearly communicated with this backend. I went back to the unminified source code to trace the HTTP requests. I searched for axios.get, fetch(, and post(. Again, almost nothing.
Then, I saw it. Tucked away in a React component was a reference to a <SignalR> component.
The application wasn't using standard REST API calls for its primary data flow. It was using SignalR — a Microsoft library for real-time web functionality over WebSockets.
I looked at the SignalR connection setup in the unminified code:
const con = conBuilder
.withUrl(props.url, {
httpClient: new MyHttpClient(new MyLogger(), props.userId),
})
.withAutomaticReconnect(retryPolicy)
.build();const con = conBuilder
.withUrl(props.url, {
httpClient: new MyHttpClient(new MyLogger(), props.userId),
})
.withAutomaticReconnect(retryPolicy)
.build();Two things stood out immediately:
- It was using a custom HTTP client called
MyHttpClient. - It was passing
props.userIdto that custom client.
In a Microsoft Teams application, props.userId typically refers to the Azure AD Object ID of the currently logged-in user. Why was a WebSocket connection receiving the user ID as a prop?
Phase 4: The Smoking Gun
I searched the source code for the MyHttpClient class definition. When I found it, I actually smiled.
class MyHttpClient extends DefaultHttpClient {
constructor(logger: ILogger, private userId: string) {
super(logger);
}
public send(request: HttpRequest): Promise<HttpResponse> {
request.headers = { ...request.headers, 'x-ms-client-userid': this.userId };
return DefaultHttpClient.prototype.send.apply(this, [request]);
}
}class MyHttpClient extends DefaultHttpClient {
constructor(logger: ILogger, private userId: string) {
super(logger);
}
public send(request: HttpRequest): Promise<HttpResponse> {
request.headers = { ...request.headers, 'x-ms-client-userid': this.userId };
return DefaultHttpClient.prototype.send.apply(this, [request]);
}
}There it was. The smoking gun.
Before a client can establish a WebSocket connection via SignalR, it must send an HTTP POST request to a /negotiate endpoint. The server responds with a WebSocket URL and a temporary access token.
The developers had created a custom HTTP client to inject the user's ID into the negotiate request as an HTTP header: x-ms-client-userid.
The critical question was: Does the Azure Function backend trust this client-supplied header to identify the user, or does it validate the Azure AD JWT?
There was only one way to find out.
Phase 5: The Exploit
I crafted a curl request to the Azure Function's /negotiate endpoint. I didn't send an Authorization header. I didn't send a JWT. I just sent a completely fake UUID in the x-ms-client-userid header.
curl -s -i -X POST -H "x-ms-client-userid: 11111111–1111–1111–1111–111111111111" "https://func-backend-live-api.azurewebsites.net/api/v1/negotiate"
The server responded:
HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8
{ "url": "https://sigr-backend-live.service.signalr.net/client/?hub=app", "accessToken": "eyJhbGciOiJIUzI1NiIsImtpZCI6Ii0zMjY1ODM1MjQiLCJ0eXAiOiJKV1QifQ.eyJhc3JzLnMudWlkIjoiMTExMTExMTEtMTExMS0xMTExLTExMTEtMTExMTExMTExMTExIi…" }
I decoded the JWT payload. It contained: "asrs.s.uid": "11111111-1111-1111-1111-111111111111"
The backend had completely bypassed Azure AD SSO. It took my spoofed header, trusted it implicitly, and minted a legitimate Azure SignalR Service JWT for my fake user.
To finalize the Proof of Concept, I wrote a quick Python script using the websockets library. I passed the stolen accessToken as a Bearer token, connected to the WebSocket URL, and sent the standard SignalR JSON handshake ({"protocol":"json","version":1}\x1e).
The server responded with {"type":6} (the SignalR ping/pong handshake completion). I was in. I had an authenticated WebSocket connection to the enterprise backend as a user who didn't even exist.
By iterating through valid Azure AD Object IDs (which are just standard UUIDs), an attacker could eavesdrop on real-time application data, intercept messages, or invoke backend Hub methods as any user in the organization.
The Lesson
This vulnerability wasn't caused by a complex memory corruption or a zero-day. It was caused by a fundamental breakdown in trust architecture.
The developers likely built the custom HTTP client to make local debugging easier. They probably thought, "We'll just pass the user ID in a header to test the SignalR connection locally without dealing with Azure AD auth." But when the code went to production, that client-side header became the single source of truth for user identity on the WebSocket layer.
Takeaways for Hunters:
- Never ignore
.mapfiles. Minified JavaScript is designed to hide logic. Source maps are designed to reveal it. Always check for them. - Understand the transport layer. If you can't find REST APIs, look for WebSockets, Server-Sent Events (SSE), or GraphQL subscriptions. The juiciest vulnerabilities are often in the real-time communication layer.
- Trace Custom Implementations. When developers override default libraries (like creating a
MyHttpClientinstead of using the default), there is almost always a reason. That reason is frequently a security flaw.
I reported the vulnerability through the bug bounty program. It was triaged as a Critical/High severity issue, and the vendor quickly patched it by enforcing JWT validation on the /negotiate endpoint and stripping the client-supplied header.