Cross Tenant Authorization
How a missing ownership check can expose records across customer accounts.
Demonstration content
The code, requests, and findings in this article come from an intentionally vulnerable demonstration application built by Veyra for research purposes. They do not describe, and must not be read as describing, any real company, customer, or disclosed incident.
Broken object level authorization is the most consequential vulnerability class in multi-tenant software, and one of the easiest to introduce. It requires no exotic technique to exploit. It usually requires changing one number in a URL.
The shape of the problem
Almost every multi-tenant application answers two separate questions on every request. Who is this? — authentication. And are they allowed to do this, to this specific object? — authorization.
The first question is easy to get right because it is answered in one place. A middleware validates a session, attaches a user, and every route inherits it. The second question is hard because it must be answered everywhere, individually, for every object a route can reach. Miss it once and the application will confidently serve one customer's data to another.
// invoices are fetched by id for any authenticated session
router.get('/invoices/:invoiceId', requireAuth, async (req, res) => {
const invoice = await db.invoices.findById(req.params.invoiceId)
if (!invoice) return res.status(404).json({ error: 'not_found' })
return res.json(invoice) // returned without ownership validation
})
requireAuth does its job perfectly. It establishes that the caller holds a valid session. It says nothing whatsoever about whether invoice 8412 belongs to them, and nothing downstream asks.
Exploitation
There is no payload and no injection. The attacker authenticates as a legitimate customer — often by signing up for a free trial — and changes an identifier.
$ curl -s https://api.demo.example/invoices/8412 -H "authorization: Bearer $ACCOUNT_A"
HTTP/2 200 {"id":8412,"account":"A","total":1840.00,...} ← legitimately theirs
$ curl -s https://api.demo.example/invoices/8413 -H "authorization: Bearer $ACCOUNT_A"
HTTP/2 200 {"id":8413,"account":"B","total":9250.00,...} ← another tenant
$ for id in {8000..8500}; do curl -s .../invoices/$id -H "..."; done
501 invoices retrieved across 47 customer accounts in 38 seconds
Two design choices turn a single-record flaw into a full extraction. Sequential integer identifiers make the address space trivially enumerable. And the absence of rate limiting on the route means five hundred requests attract no more attention than five.
Note also what the logs would show: a valid session, a 200 response, a normal endpoint. Nothing that looks like an attack. Most teams discover this class of issue when a customer reports seeing someone else's data, not from monitoring.
Why it gets missed
- It works perfectly in testing. Developers test as one user with their own data. The bug is invisible unless you deliberately test as two tenants.
- The UI never offers the URL. The application only ever links to invoices you own, so the flaw is unreachable through normal use — and unreachable by any test that drives the UI.
- Authentication looks like authorization. A route decorated with
requireAuthlooks protected in review. Reviewers see a guard and move on. - It scales badly with team growth. The pattern must be applied on every new route forever. A team that adds forty endpoints a quarter needs to get it right forty times a quarter.
- Generic scanners do not find it. There is no dangerous function and no tainted input. Detecting it requires understanding that
invoiceIdidentifies a tenant-owned object and that no check ties it to the caller.
Detection at scale
Finding this reliably across a codebase means answering three questions together, which is why it needs correlation rather than pattern matching:
- Which route parameters identify tenant-owned objects? Derived from the data model: which tables carry an account or organization foreign key.
- Does the handler constrain the query by the caller's tenant? Either directly in the query, or through a scoped repository, or via a policy check between fetch and response.
- Is the route reachable, and what does the object contain? A public route returning financial records is critical. An internal route behind a VPN returning a display preference is not.
HOW VEYRA DETECTS THIS
- Veyra Code maps the data model and identifies
invoicesas tenant-scoped by itsaccountIdforeign key, then tracesreq.params.invoiceIdinto an unscopedfindById. - Veyra API records that
GET /api/invoices/{invoiceId}requires authentication, has no ownership check, returns financial data, and is publicly reachable. - Veyra Surface confirms the route responds on an internet-facing host.
- Veyra Intelligence correlates all three into one attack path, and raises severity because the impact is cross-tenant and the data is financial.
- Veyra Verify reproduces the issue safely in an authorized environment using two accounts the customer controls, and marks the finding verified at 96% confidence.
Result: one critical finding with a reproduction, rather than four medium alerts nobody connects.
Fixing it properly
The instinctive fix is to add a check to the handler:
const invoice = await db.invoices.findById(req.params.invoiceId)
if (invoice.accountId !== req.user.accountId) return res.status(404).end() // works, but fragile
This is correct and it will be forgotten. The durable fix moves enforcement to the data layer, so that writing an unscoped query becomes the unusual thing a reviewer notices rather than the default:
const invoice = await db.invoices.findOne({
id: req.params.invoiceId,
+ accountId: req.user.accountId, // scope every query to the caller's tenant
})
if (!invoice) return res.status(404).json({ error: 'not_found' })
Stronger still, in rough order of durability:
- Request-scoped repositories. Resolve the tenant once per request and expose a data accessor that cannot query outside it. The unsafe query becomes impossible to write by accident.
- Row-level security in the database. Postgres RLS enforces the boundary even if application code is wrong. It survives the ORM being bypassed, which application checks do not.
- Return 404, never 403. A 403 confirms that invoice 8413 exists. For a cross-tenant request, the resource should not appear to exist at all.
- Non-sequential identifiers. UUIDs do not fix authorization — they raise the cost of enumeration. Treat this as defence in depth, never as the control.
- A two-tenant integration test. One test that creates two accounts and asserts that A receives a 404 for B's object catches every future regression of this class. It is the highest-value test most multi-tenant applications do not have.
What to take away
Authentication is a single control applied once. Authorization is a decision repeated on every object access in your application, forever, by every engineer who joins. Any process that relies on remembering it will eventually fail — not through carelessness, but through volume.
Move the boundary into a place where forgetting is structurally impossible, then monitor continuously for the routes that slip past it anyway.
Does this exist in your application?
A baseline assessment answers that question against your own authorized code, APIs, and external surface.