One Signup, Another Bank's Loan Book
Open signup plus a missing ownership check let one bank read another bank's borrower data on an AI lending platform. Here is the chain and the fix.
The setup
The target was an AI lending platform whose customers are banks and other lending institutions. It sits between a lender and its borrowers, automating the assessment and management of loans. The data it holds is about as sensitive as commercial data gets: borrower identities, loan financials, and the bank-transfer details that move real money.
A platform like this lives or dies on one promise. Each institution using it is a separate tenant, and every tenant’s data must be walled off from every other. Bank A must never see Bank B’s loan book. That guarantee is the product. So the question we went in to answer was simple: is the wall real, or is it assumed? We worked black-box, from the open internet, with no prior access.
Recon
The first thing we look for on a multi-tenant product is how someone becomes a tenant in the first place. On this platform, the signup flow was open. Anyone could self-provision an account and land inside a tenant of their own, with no invitation, no verification, and no human in the loop.
Open signup is not itself a vulnerability. Plenty of sound products let anyone register. What it does is remove a barrier: it hands an attacker a legitimate, authenticated position inside the application for free. From there, the only thing standing between one tenant and another is whether the backend actually enforces the wall.
The hypothesis
Multi-tenant applications usually get authentication right. You log in, you get a session, the session says who you are and which tenant you belong to. The place the wall tends to crack is one step deeper: on each individual data-returning call, does the code re-check that the record you asked for belongs to your tenant, or does it just trust that because you are logged in, you must be allowed to see it?
That second habit - trusting tenant scope from the session instead of enforcing it per query - is the single most common way tenant isolation fails. So the hypothesis was: open signup gets us inside cheaply, and somewhere in the backend a procedure returns data without checking ownership. For banking data, if that were true, it would be close to worst-case.
What we tried
We began by doing the honest, safe thing: we provisioned two of our own accounts through the open signup flow, landing in two separate tenants we fully controlled. This is how you test tenant isolation without ever touching a real customer’s data - you make yourself both the attacker and the victim.
With one account, we walked the application’s backend procedures - the individual calls it makes to fetch data - and watched how each one identified the records it returned. Most of the surface behaved. Then a handful of procedures returned data scoped to a tenant identifier that we could change. We changed it to our second, unrelated tenant and the call returned that tenant’s data. The procedure had identified the record correctly and never asked whether the caller was entitled to it.
Having proven the mechanism with our own two accounts, we did the one careful confirmation that shows real-world impact: we verified that the same class of call, from one tenant, returned data belonging to a different, genuine organization on the platform. We confirmed the read was cross-tenant and real, and we retained no borrower data. The point was to prove the wall was missing, not to collect anyone’s loan book.
What we found
A small set of backend procedures returned another organization’s data with no ownership or tenant check. From a position inside one tenant, those procedures exposed a different institution’s:
- Borrower PII - the personal identifying information of another bank’s customers.
- Loan financials - the terms, balances, and status of another institution’s loans.
- Bank-transfer details - the account information used to move money.
-> POST /<backend>/<procedure>
Cookie: <our-tenant-session>
{ "tenantId": "<another-orgs-id>", ... }
<- 200 OK
{ ... another organization's borrower PII,
loan financials, and transfer details ... }
The session was ours. The tenant identifier in the request was not. The server honored the identifier in the request and never reconciled it against the tenant the session belonged to. That single missing check turned an open signup into a read across the platform’s tenant boundary - the exact wall the product exists to guarantee.
Severity here comes from the combination, not either half alone. Open signup on its own is fine. A missing ownership check on an internal-only tool is bad but bounded. Put them together on a platform holding regulated banking data and you have anonymous, self-service access to other institutions’ loan books.
Why it happens
This is a normal consequence of how multi-tenant code grows, not a lapse of competence.
Early in a product’s life, tenant scope gets established at the session layer - you log in, the app knows your tenant, and every screen you see is already filtered to you. Because the UI only ever sends you your own tenant’s identifier, it is easy for a backend procedure to trust the identifier it receives rather than re-derive it from the session. The check feels redundant while the only client is your own front end. It stops being redundant the moment someone crafts a request by hand.
Open signup removes the last practical barrier. On a product that onboards banks through contracts and sales calls, a developer might reasonably assume every account belongs to a vetted institution. Self-service registration quietly breaks that assumption: now anyone can hold a valid session, and the missing per-query check is all that is left.
For developers
If you run a multi-tenant platform, these controls close this class:
- Enforce a tenant and ownership check on every procedure that returns data. Not at login - on each call. The right question for every query is not “is this user authenticated?” but “does this specific record belong to this user’s tenant?”
- Derive tenant scope from the trusted session, never from the request body. The tenant identifier the client sends is an input an attacker controls. The tenant identifier attached to the authenticated session is not. Filter every query by the session’s tenant and reject anything that does not match.
- Gate self-signup for regulated data. A product handling banking or other regulated financial data should not let anyone self-provision into a live tenant. Put verification, invitation, or manual approval in front of account creation so a valid session is not free to obtain.
- Audit with a two-account test. Create two tenants you control and, from one, attempt to read the other’s records through every data-returning endpoint. Any success is a cross-tenant IDOR. This test is cheap, repeatable, and belongs in your regression suite.
The takeaway
Tenant isolation is not something you configure once at the login screen - it is a check you owe on every single call that returns data. This platform authenticated users correctly and still let one bank read another’s loan book, because a handful of procedures trusted the tenant identifier in the request instead of the one in the session, and open signup meant that request cost nothing to make. When the product’s whole promise is a wall between institutions, the wall has to be enforced per query, or it is not there at all.
This teardown is one instance of a larger lesson: real severity comes from chaining an easy foothold to a missing check. We wrote up the general case in Chaining: Where Real Severity Comes From.
Frequently asked
What is cross-tenant IDOR?
Why is broken tenant isolation so serious for banking platforms?
How do you prevent cross-tenant data access?
This is one finding from a harness that runs continuously. See how Greywatch finds, proves, and fixes them.
How it works →

