The missing tenant column
A multi-tenant SaaS application keeps every customer's data in the same database and promises each of them they will only ever see their own. That promise is usually kept by one line repeated in hundreds of places: WHERE tenant_id = ?. Every query that remembers it is correct. The one that forgets shows a customer another company's records.
Cross-tenant leaks are the business-to-business version of the IDOR bugs we wrote about earlier. The difference is the blast radius. An IDOR exposes one user's record to another user. A cross-tenant leak exposes one customer's company to another customer's staff, and tends to end with an incident notice sent to every affected account.
What it looks like when it happens
A new integration and a scoping bug
In June 2025 Asana told customers that a bug in its newly launched MCP server, the interface that lets AI assistants query Asana, "could have potentially exposed certain information from your Asana domain to other Asana MCP users", as quoted by The Register. The server had launched on 1 May. Asana found the problem on 4 June and took the feature offline until 17 June, resetting every connection. A spokesperson told BleepingComputer that roughly 1,000 customers were affected, and described it as a logic flaw rather than an attack. There was no indication anyone exploited it.
Asana has not published the details of the coding error, so we will not guess at them. The shape is still instructive: a new access path into existing data, built later than the main application, did not carry the same tenant boundary.
A cache that returned the wrong user's data
In March 2023 OpenAI took ChatGPT offline after some users saw the titles of other users' conversations. In its postmortem, OpenAI traced the cause to a bug in the Redis client library: a cancelled request could leave a connection in a state where the next request received data cached for an unrelated user. OpenAI said the same bug may have exposed payment-related details (name, email, payment address, card type, the last four digits and expiry date) for 1.2% of ChatGPT Plus subscribers active in a nine-hour window. Full card numbers were not exposed.
One of OpenAI's fixes is the most portable lesson in the report: it added checks that the data returned from the cache matches the user who asked for it. That is a tenant check at the point of read, independent of whatever upstream code was supposed to get it right.
Where cross-tenant leaks come from
Most cross-tenant bugs are one of a few shapes:
- A query that forgets the filter. The repository function takes an id and loads the row. Everything that calls it is supposed to pass a tenant-scoped id. One caller does not.
- A tenant id taken from the request. The handler reads
tenantIdfrom the body or a header instead of from the authenticated session, so any user can name any tenant. - A table with no tenant column at all. It was added later, or it hangs off a row that is tenant-scoped only by convention. Every query against it must scope through a join, by hand, forever.
- A side door. A cache key, a background job, a websocket broadcast, an export or an AI integration that reads the data through a path the main request pipeline does not cover.
The third shape is the one the schema shows directly.
// Drizzle schema: most tables are tenant-scoped...
export const projects = pgTable('projects', {
id: uuid('id').primaryKey(),
orgId: uuid('org_id').notNull().references(() => orgs.id),
name: text('name').notNull(),
});
export const invoices = pgTable('invoices', {
id: uuid('id').primaryKey(),
orgId: uuid('org_id').notNull().references(() => orgs.id),
amountCents: integer('amount_cents').notNull(),
});
// ...but this one is not, and it holds personal data
export const attachments = pgTable('attachments', {
id: uuid('id').primaryKey(),
uploadedByEmail: text('uploaded_by_email').notNull(),
storageKey: text('storage_key').notNull(),
});
Nothing in the data model ties an attachment to an organisation. A route like GET /attachments/:id has no column to filter on even if the developer remembers to. The fix is to give the table its boundary:
export const attachments = pgTable('attachments', {
id: uuid('id').primaryKey(),
orgId: uuid('org_id').notNull().references(() => orgs.id),
uploadedByEmail: text('uploaded_by_email').notNull(),
storageKey: text('storage_key').notNull(),
}, (t) => ({ orgIdx: index('attachments_org_idx').on(t.orgId) }));
A non-null key to a parent row that is itself tenant-scoped, such as a project_id, works too, as long as every query goes through it.
Make scoping structural, not remembered
A tenant column makes correct queries possible. It does not make them happen. The durable fixes move scoping out of individual queries.
One scoped data-access layer. Every query for tenant data goes through a repository or ORM default scope that injects the tenant from the authenticated session. Handlers never construct the tenant filter themselves, and never read the tenant id from the request.
// Vulnerable: tenant id chosen by the caller
app.get('/api/invoices', requireAuth, async (req, res) => {
const rows = await db.invoice.findMany({ where: { orgId: req.query.orgId } });
res.json(rows);
});
// Fixed: tenant id comes from the session, never the request
app.get('/api/invoices', requireAuth, async (req, res) => {
const rows = await db.invoice.findMany({ where: { orgId: req.user.orgId } });
res.json(rows);
});
Row-level security in the database. PostgreSQL can enforce the boundary itself. A policy is attached to the table and applied to every query, including the ones nobody reviewed:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (org_id = current_setting('app.current_org')::uuid);
The application sets app.current_org for each request or transaction from the authenticated session. The PostgreSQL documentation spells out the details worth knowing. With row security enabled and no policy, a default-deny applies. A USING clause filters the rows a command can see, and with no separate WITH CHECK it also limits the rows a command can write. Table owners bypass policies unless the table is set to FORCE ROW LEVEL SECURITY, and superusers and roles with BYPASSRLS always bypass them. So the application must not connect as the table owner or a superuser, or the policy does nothing.
Check on the way out. As OpenAI did, compare the tenant on the data you are about to return with the tenant of the caller, especially on cached and asynchronous paths.
What Vulkro checks
Since 0.34.0, every scan maps the application's database schema from its ORM models and migrations, and marks tenant columns such as tenant_id, org_id and workspace_id. The schema-tenant-column-missing rule (Medium) uses that map. A schema counts as multi-tenant when at least four tables, and more than half of all tables, carry a tenant column. Vulkro then reports each table that holds personal, health, financial, credential or secret data, has no tenant column and has no key to a tenant-scoped table. In a monorepo each package is judged on its own. A lookup table that is global by design can be marked with a vulkro:disable comment on its declaration.
The other multi-tenancy checks look at code rather than the schema:
mt-tenant-id-from-request: a tenant id read from the request body rather than the session, the first vulnerable example above.mt-missing-tenant-filterandmt-job-no-tenant-recheck: a query on a tenant-scoped table with no tenant filter, and a background job that acts on tenant data without re-checking the tenant.ws-cross-tenant-broadcast: a websocket broadcast that is not scoped to a tenant.idor-authn-no-ownership: an authenticated lookup by a caller-supplied id with no ownership or tenant scope.
We are explicit about what these can and cannot prove. A missing filter is an absence, and the guard that makes it safe may live where a static scan cannot see it: a global ORM scope, a database policy, a decorator in another file. So the absence-based checks are reported at Low confidence and are labelled as not checked rather than proven. They are leads to review: they stay in the report, but by default they are outside the main view and do not fail a build. The schema rule, by contrast, rests on something Vulkro can see directly: the column is either declared or it is not.
In the local console, the schema map links each table to the endpoints that read and write it, so a table without a tenant column shows up next to the routes that would leak it. The console's attack paths then show which of those routes are reachable, and by whom.
Where to see it
Run vulkro scan on a multi-tenant codebase and look for schema-tenant-column-missing. The rule page has the exact conditions, the database schema docs cover the map, and attack paths explain how findings are joined into reachable paths. To get set up, start here.
Sources
- The Register, Asana MCP server bug, 18 June 2025
- BleepingComputer, Asana warns MCP AI feature exposed customer data to other orgs, June 2025
- OpenAI, March 20 ChatGPT outage: here's what happened, March 2023
- PostgreSQL, Row Security Policies
