Home/Journal/Teardown
TeardownCriticalidorfintechaccess-control

The Onboarding Link That Was a Skeleton Key

A payments fintech treated its onboarding UUID as a secret. It was not authorization. How one signup read another merchant's underwriting PII.

Rahul Dharan··8 min read·a merchant-underwriting fintechIDOR on a sensitive object (CWE-639/862)

The setup

The target was a B2B fintech in merchant underwriting - the part of payments that decides whether a business can accept cards and on what terms. To make that decision, the platform collects the most sensitive paperwork a business hands over: government ID numbers of the owners, bank and routing numbers, business tax IDs. That data lives in an onboarding application, one object per merchant, and the whole point of underwriting is that it is confidential to the merchant and the platform.

That concentration of regulated PII in a single object type is what made it worth a careful look. When one kind of record holds everything an identity thief or a fraudster would want, that record’s access control is the thing that has to be right. We went in black-box, from the open internet, with no account.

Recon

As on any product that onboards businesses, the first question was how you get inside. Self-registration was open: anyone could create an account and reach the application without an invitation or a vetting step.

Once registered, we looked at how the platform referred to onboarding applications. Each one was addressed by an opaque identifier - a UUID, the long random string that is effectively impossible to guess. The application fetched a merchant’s onboarding object by putting that identifier in the request. Nothing about the record’s address was human-readable or sequential. It looked, at a glance, like a well-designed private reference.

The hypothesis

An opaque identifier invites a specific and common mistake. Because a UUID cannot be guessed or enumerated, it is tempting to treat it as if it were a secret - as if holding the ID were proof you are allowed to see what it points to. That reasoning quietly collapses two different things: identification and authorization. The UUID identifies which record you want. It says nothing about whether you are permitted to have it.

These onboarding identifiers also travel. An onboarding link gets emailed to a merchant, sits in browser history, gets logged by proxies, is pasted into support tickets. An identifier that leaks that easily was never a credential. So the hypothesis was: the platform checks that the UUID is valid, and never checks that the requester owns the object behind it. If so, the link is not a private reference - it is a skeleton key that opens any merchant’s application.

What we tried

We did this with two accounts we created ourselves through the open registration flow, so that both the “attacker” and the “victim” were us. That is how you prove an ownership check is missing without ever reading a real customer’s data.

From the first account, we submitted an onboarding application and noted its identifier. From the second, entirely unrelated account, we requested that first application’s object by its identifier. The server returned it in full - the PII we had entered into the other account, handed to a session that plainly did not own it. There was no ownership check between the two accounts at all. The UUID was the only thing standing in for authorization, and a UUID is not authorization.

Having proven the mechanism on our own two objects, we stopped. We did not attempt to enumerate identifiers, and we did not request or pull any real merchant’s application. The finding was established: a registered user could read an onboarding object they did not own by presenting its identifier. Demonstrating the shape of the flaw does not require touching anyone’s real data, and we did not.

What we found

Any authenticated user could read any merchant’s onboarding application by supplying its identifier. Behind that object sat the full underwriting record:

-> GET /<api>/onboarding-applications/<another-merchants-uuid>
   Cookie: <our-second-account-session>

<- 200 OK
   { ... the other application's underwriting PII:
         government ID numbers, bank and routing numbers,
         business tax IDs ... }

The request carried a valid session for one account and the identifier of an object owned by another. The server validated the identifier and returned the object, and at no point asked whether the two belonged together. The unguessable UUID was doing a job it was never suited for: standing in as the sole gate on regulated financial PII.

The severity is in the object, not the volume. We proved this on records we controlled, but the same call shape reaches any merchant’s application. One missing check exposes the most sensitive document each business on the platform ever submits.

Why it happens

This is a natural mistake, not a careless one, and it comes from the identifier design working exactly as intended.

When a team moves from sequential integer IDs to UUIDs, they do it for good reasons - to stop enumeration and to make references safe to expose. The unintended side effect is a feeling of safety. The IDs are now unguessable, so the ownership check on each read starts to feel like belt-and-suspenders and quietly never gets written. The design that was supposed to be one layer of defense gets treated as the whole defense.

The onboarding flow reinforces the trap. An onboarding link is meant to be shared with the merchant it belongs to, so the identifier is deliberately handed out. Once an identifier is something you send to a user, it is something that can be replayed by anyone who ends up holding it. And open self-registration means holding a valid session costs nothing, so “only authenticated users can call this” is not the protection it sounds like.

For developers

If you expose objects by identifier, these controls close this class:

  1. Put an ownership and authorization check on every object read. For each fetch, the server must confirm the authenticated caller is entitled to that specific object, independent of how the object was addressed. “The ID resolved” is not “the caller is allowed.”
  2. Treat identifiers as capabilities, not secrets. A UUID selects a record. It never grants permission to it. Design as if every identifier will eventually leak - because links, logs, and caches guarantee it - and let a separate permission check decide access.
  3. Do not let unguessability substitute for authorization. Random IDs are good hygiene and stop enumeration, but they are one control, not the control. Keep the ownership check even when the ID looks impossible to guess.
  4. Gate self-registration. A product holding underwriting PII should not let anyone self-provision a session for free. Verification or approval in front of account creation removes the cheap authenticated position this attack depends on.
  5. Audit with a two-account test. From one account, request objects created by a second account you control. Any success is an IDOR. Make it a standing test for every sensitive object type.

The takeaway

An unguessable identifier is not a permission. This fintech addressed its onboarding applications with UUIDs - the right way to name a record - and then let the UUID stand in as the only gate on government IDs, bank numbers, and tax IDs, so any registered user could read any merchant’s underwriting file by presenting its identifier. A link that is shared with a merchant becomes a capability anyone can replay, and the fix is the same on every object you expose: check ownership on the read, no matter how random the ID.

This teardown is one instance of a pattern about what a new, easily obtained account is allowed to do. We wrote up the general case in When New Accounts Are Born as Admin.

Frequently asked

What is an IDOR vulnerability?
IDOR, or insecure direct object reference, is when an application lets you access an object by its identifier without checking that you are allowed to see that object. You change the ID in a request to one you do not own, and the server returns it anyway. The identifier acts as if it were a password, when it should only select the record and a separate authorization check should decide access.
Is a UUID enough to protect a sensitive object?
No. A UUID is unguessable, which makes it a good identifier, but being hard to guess is not the same as being authorized. UUIDs get shared in links, logged, cached, and forwarded, so they leak. Any object read still needs an explicit ownership check regardless of how random the ID is.
How do you fix IDOR on onboarding or application objects?
Add an ownership and authorization check to every object read, independent of how unguessable the ID is. Treat an identifier as a capability that still requires a permission check, not as a secret. Gate self-registration so obtaining a valid session is not free, and audit sensitive object reads with a two-account test.

This is one finding from a harness that runs continuously. See how Greywatch finds, proves, and fixes them.

How it works →