// Broken: trusts the client
app.get('/api/reports', (req, res) => {
const tenantId = req.headers['x-tenant-id']; // attacker-controlled
const rows = db.query('SELECT * FROM reports WHERE tenant_id = ?', [tenantId]);
res.json(rows);
});
// Fixed: derived from the verified session, every time
app.get('/api/reports', requireAuth, (req, res) => {
const tenantId = req.session.tenantId; // set at login, not trusted from the request
const rows = db.query('SELECT * FROM reports WHERE tenant_id = ?', [tenantId]);
res.json(rows);
});
Why this happens
It usually isn't a junior mistake. It's a shortcut that made sense at the time: the frontend already knows which workspace it's in, so it seemed reasonable to pass that along as a header rather than look it up again server-side. The endpoint was probably built and tested by one person, on one account, and the missing check never showed up because nobody tried to break it on purpose.
It also survives code review more often than it should, because the code reads correctly. The query is parameterized, there's no SQL injection, the tenant filter is right there in the WHERE clause. What's missing isn't visible in the query. It's missing in the line above it, where the tenant ID came from in the first place.
What an attacker actually does
No credential theft, no exploit chain. They authenticate normally as a legitimate user on any tenant, open the network tab, find the request that lists their own reports, and replay it with a different tenant identifier. If the backend doesn't re-derive the tenant from the session, it returns the other tenant's rows. This is the single most common finding in the architecture reviews we run for multi-tenant SaaS, and it's usually reachable from a feature nobody thought of as security-sensitive: exports, webhooks, admin impersonation, internal reporting dashboards.
Where to look
- Any endpoint that reads a tenant, workspace, or organization identifier from a header, query string, or request body instead of the session.
- Endpoints added after the main authorization middleware was written: they're the ones most likely to have their own ad hoc tenant handling.
- Background jobs and webhooks that receive a tenant ID as a payload field and never re-check it against anything.
- Any place a JWT claim is read directly without confirming the token was issued for the session currently in use.
The fix, in general form
- Resolve the tenant identifier exactly once, at the authentication boundary, from the verified session or token, never from a client-supplied field.
- Attach it to the request context so every downstream handler reads the same value.
- Enforce it again at the data layer (a row-level security policy, or a repository method that always requires a tenant ID as an argument) so a single missed check upstream isn't enough to leak data.
- Add one test per tenant-scoped endpoint that asserts a second tenant's data is unreachable, not just that the happy path works.
What this satisfies
This is the control a SOC 2 auditor is checking for under CC6.1 (logical access controls) and what ISO 27001 Annex A 8.3 (information access restriction) expects in practice. It's also one of the first things a customer's security questionnaire asks about directly: "how is tenant data logically separated?" A one-sentence answer that names where the tenant ID is derived and where it's enforced again is a stronger answer than a paragraph about encryption at rest.
