August 17, 2026
Bug Bounty: When a “Random” UUID Wasn’t Random Enough — Cross-Tenant Credential Leak in an ATS…
بسم الله الرحمن الرحيم، والصلاة والسلام على أشرف المرسلين سيدنا محمد ﷺ.

By Pulga (bettercallme)
5 min read
دي فكرة بسيطة عن إزاي جبت الـ IDOR والـ approach اللي مشيت بيه لحد ما وصلت للـ vulnerability.
Behind the story
I was poking around a recruiting SaaS platform's integrations settings — the page where a company hooks up its ATS (Greenhouse, Workday, Bullhorn, that kind of thing) so the platform can pull job and candidate data on their behalf. Nothing glamorous. I was mostly just clicking through menus and watching the proxy history using the Burp , the way you do when you're bored and looking for something to poke at.
One request stopped me mid-scroll:
GET /api/integrations-proxy/connections?orgId=<my-org-guid>GET /api/integrations-proxy/connections?orgId=<my-org-guid>My own org, my own empty list of connections ([] — I hadn't set anything up). Nothing to see here, except... the orgId was just sitting there in the URL. Not in a signed cookie, not derived server-side from my session — right there, in plain sight, mine to edit.
You know that feeling when a lock looks a little too easy? That was this.
Analyze
Lesson one of these bugs: the ID being "just a random UUID" is never actually the end of the story. Someone, somewhere, usually has a reason to hand that ID out. You just have to go find them.
I went hunting through the login/SSO flow and found an endpoint that resolves a company's email domain to an SSO redirect — completely unauthenticated, no session needed:
GET /api/auth/ssoEmail?email=anyone@target-company.com
{ "redirectUrl": ".../api/auth/sso/<target-company-org-guid>?..." }GET /api/auth/ssoEmail?email=anyone@target-company.com
{ "redirectUrl": ".../api/auth/sso/<target-company-org-guid>?..." }And there it was. That UUID buried in the redirect URL? Same shape, same format as the orgId from before. Turns out this "unguessable random identifier" the whole access model was quietly leaning on could be requested by anyone, for any company, just by knowing their email domain. No login. No rate limiting I could find. Easy peasy lemon squeezy — except it really shouldn't have been.
That's the thing about UUIDs as a security boundary: they're only as secret as the least careful endpoint that ever hands them out. You can have twenty endpoints doing the right thing and one convenience feature quietly undoing all of it.
So, GUID in hand, I went back to the connections request and swapped mine for theirs. Same session. Same cookies. Just a different four characters in the query string.
GET /api/integrations-proxy/connections?orgId=<target-company-org-guid>GET /api/integrations-proxy/connections?orgId=<target-company-org-guid>And the floodgates opened. Twenty-two connection records, live and validated — Greenhouse, Workday, Bullhorn, Talemetry, Heymarket — and every single one came with its actual credential sitting in the response. Not a field name. Not a masked ••••••. The real token.
{
"atsType": "heymarket",
"validationStatus": "validated",
"orgDomain": "target-company.com",
"userAuthData": { "token": "<the actual, working token>" }
}{
"atsType": "heymarket",
"validationStatus": "validated",
"orgDomain": "target-company.com",
"userAuthData": { "token": "<the actual, working token>" }
}Pulling on the thread a little further
Here's where it stopped being "one endpoint forgot a check" and started looking like a pattern. One of the leaked connection IDs, dropped as a header on a completely different endpoint…
GET /api/integrations-proxy/jobs/jobDescriptions
X-Account-ConnectionId: <foreign connection id>GET /api/integrations-proxy/jobs/jobDescriptions
X-Account-ConnectionId: <foreign connection id>…handed back that foreign company's actual job data, my own org header sitting right there in the request, doing absolutely nothing. The connection ID itself was acting like a bearer token — whoever holds it, gets in. That's the kind of detail that tells you the fix isn't "add one if statement," it's "go check every place this ID gets trusted."
The part where I proved it could bite, not just peek
My first report was read-only on purpose — I don't touch what isn't mine. Fair enough, the triager scored it without integrity and availability impact, since nothing was actually created or broken. But I wasn't fully satisfied with "it can only read," so I went back looking for a way to prove it further, safely.
The create part came first, and it was almost anticlimactic
The same endpoint that returned the connections list also accepted a POST, and — this was the part I had half-expected but still had to check — it used the exact same orgId query parameter as its tenant selector, with the exact same missing check. No separate authorization logic for writes. I built a minimal, obviously-fake connection record, gave it a name nobody could mistake for real data, and pointed it at the foreign org:
POST /api/integrations-proxy/connections?orgId=<target-company-org-guid>
{
"name": "RESEARCH-MARKER-<random>",
"atsType": "bullhorn",
"userAuthData": { "onlyProspects": false },
"orgDomain": "target-company.com"
}POST /api/integrations-proxy/connections?orgId=<target-company-org-guid>
{
"name": "RESEARCH-MARKER-<random>",
"atsType": "bullhorn",
"userAuthData": { "onlyProspects": false },
"orgDomain": "target-company.com"
}200 OK, and the response echoed back a generated id for the new record. That alone doesn't prove much — plenty of APIs will happily accept and silently drop a write. So I didn't trust the 200. I went back to the read endpoint and counted: 22 connections before, 23 after, my marker sitting right there in the list. That's the part that actually proves persistence — not the response code, the state change.
The delete was the trickier one, and honestly the part I spent the most time on
There wasn't a DELETE verb anywhere on this resource. No DELETE /connections/{id}. No obvious "remove" action in the UI I could trace a request from. Just GET and POST. My first assumption — that deletion probably wasn't reachable through this bug at all — turned out to be wrong, but I only found that out by paying attention to how the create response was shaped rather than just what it said.
The create response wasn't just an ID back — it echoed the entire record, every field I'd sent plus a few the server had filled in: dateCreated, version, isDeleted: false. That last field was the tell. If the server was willing to hand me back an isDeleted flag on create, there was a decent chance the same endpoint was doing an upsert — meaning if I posted again with the same id I'd already been given, instead of creating a second record it would overwrite the first one in place.
So that's what I tested: took the exact record the server had returned to me, changed nothing structurally, just flipped isDeleted to true and renamed it so it'd be obvious in any logs what happened:
POST /api/integrations-proxy/connections?orgId=<target-company-org-guid>
{
"id": "3f8tb8h68m",
"name": "RESEARCH-MARKER-<random>-DELETED-BY-RESEARCHER",
"atsType": "bullhorn",
"userAuthData": { "onlyProspects": false },
"orgDomain": "target-company.com",
"isDeleted": true
}POST /api/integrations-proxy/connections?orgId=<target-company-org-guid>
{
"id": "3f8tb8h68m",
"name": "RESEARCH-MARKER-<random>-DELETED-BY-RESEARCHER",
"atsType": "bullhorn",
"userAuthData": { "onlyProspects": false },
"orgDomain": "target-company.com",
"isDeleted": true
}200 OK again, same ID handed back, isDeleted: true in the response. Still not proof on its own — I needed to see it actually leave the active inventory, the same way I'd needed to see the count go up on create. Back to the read endpoint: 23 connections became 22 again. The marker was gone from the list a normal recruiter would see.
What made this satisfying rather than just mechanical was that there was no "delete" feature to abuse — I had to notice that create and delete were secretly the same operation wearing different clothes.
That full loop — create something in a stranger's tenant, then reach back in and erase it, using nothing but query-string tampering and a GUID handed out for free — was what actually got this reclassified. It wasn't "I read data I shouldn't have," it was "I can materially change another company's environment," and that distinction moved the severity from High to Critical.
What I'd tell the next person building this
- A UUID is not a security boundary just because it's random. Ask yourself: does anything else in the product hand this value out? If yes, it's public now, whether you meant it to be or not.
- Tenant scope belongs in the session, not the query string. If the client can type it, the client can lie about it.
- Fix the read path and stop there, and you've fixed nothing — check every verb that touches the resource. Mine turned out to have a write problem too, and I only found it because I went looking on purpose.
- If you're storing live secrets that get echoed back in API responses, mask them. Even the legitimate owner shouldn't need to see the raw value on every page load.