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.

Demonstration application · src/routes/invoices.ts
// 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.

Authorized demonstration environment
$ 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 requireAuth looks 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 invoiceId identifies 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:

  1. Which route parameters identify tenant-owned objects? Derived from the data model: which tables carry an account or organization foreign key.
  2. 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.
  3. 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

  1. Veyra Code maps the data model and identifies invoices as tenant-scoped by its accountId foreign key, then traces req.params.invoiceId into an unscoped findById.
  2. Veyra API records that GET /api/invoices/{invoiceId} requires authentication, has no ownership check, returns financial data, and is publicly reachable.
  3. Veyra Surface confirms the route responds on an internet-facing host.
  4. Veyra Intelligence correlates all three into one attack path, and raises severity because the impact is cross-tenant and the data is financial.
  5. 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:

Adequate, but it must be repeated on every route forever
  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:

Better — ownership is part of the query, not a separate step
  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.