Skip to content
advanced Phase 10 · Authentication and Authorization for GraphQL APIs

Multi-tenant Data Scoping

Keeping each tenant's rows isolated when resolvers query a shared database.

55m
2 problems
Topic Progress 0%

The Missing WHERE Clause

The Missing WHERE Clause

Multi-tenant breaches rarely look like hacks. They look like a forgotten filter:

// broken: the id alone decides what comes back
Query: {
  invoice: (_, { id }) => db.oneOrNone('SELECT * FROM invoices WHERE id = $1', [id]),
},

Any authenticated caller can walk ids — invoice(id: "41"), invoice(id: "42") — and read other companies' invoices. The tenant exists everywhere in the system except the statement that fetches data.

The fix belongs in the query, and the value comes from verified claims:

Query: {
  invoice: (_, { id }, ctx) =>
    db.oneOrNone(
      'SELECT * FROM invoices WHERE id = $1 AND tenant_id = $2',
      [id, ctx.viewer.tenantId]                 // set during authentication
    ),
},

Two tempting shortcuts, both wrong:

Fetch-then-check. const row = await get(id); if (row.tenantId !== ctx.viewer.tenantId) throw … still shipped the row out of the database, requires the same check at every call site, and makes timing and error differences observable. Filtering in SQL means the row never leaves the store for a caller who may not see it.

Client-supplied scope. invoice(id: ID!, tenantId: ID!) hands the filter to the attacker. Tenant comes from the token claim or from a host header you resolve server-side to a tenant record — never from an argument the request invents. The same applies one level down: loader batches must carry the scope in both the cache key and the statement:

new DataLoader(async (ids) => {
  const rows = await db.query(
    'SELECT * FROM invoices WHERE tenant_id = $1 AND id = ANY($2)',
    [viewer.tenantId, ids]
  );
  ...
});

The rule: every statement that touches tenant data has a tenant predicate, including the ones written for background jobs, exports, and "temporary" admin scripts. Grep for the table name and count predicates — that audit is faster than any framework.

Row-Level Security as a Second Layer

Row-Level Security as a Second Layer

Application filters depend on every developer remembering them forever. PostgreSQL row-level security (RLS) enforces the same rule inside the database, so a forgotten predicate returns nothing instead of another tenant's rows:

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.current_tenant_id', true));

The application sets the scope once per transaction, and the predicate applies to every statement automatically:

BEGIN;
SET LOCAL app.current_tenant_id = 'acme';
SELECT * FROM invoices;               -- acme rows only, no explicit filter
-- role without BYPASSRLS cannot opt out
COMMIT;

Defense in depth, with honest caveats:

  • It catches the bug you have not written yet. A new repository method missing WHERE tenant_id = still cannot cross tenants. That is the whole argument for the second layer.
  • SET LOCAL is transaction-scoped; a pooled connection that skips the transaction leaks scope. Use SET inside a transaction you control, and RESET ALL (or a fresh connection) on release so the next borrower does not inherit acme's identity. This is the single most common RLS footgun.
  • The database role matters. The table owner and roles with BYPASSRLS are exempt by design — an application role must not be the owner.
  • Background jobs have no request. Workers must set an explicit tenant context per unit of work or run as a bypass role with its own audit trail.

RLS does not remove the need for the application filter: it protects reads, while your queries still decide which columns and joins are appropriate, and it does nothing for object-level checks inside a single tenant.

Enumeration, Object Checks, and Loader Scope

Enumeration, Object Checks, and Loader Scope

Isolation that returns the wrong answer still leaks. Three leak paths survive a correct tenant filter:

Distinguishing not-yours from does-not-exist. FORBIDDEN for another tenant's id and NOT_FOUND for a random id is a membership oracle: an attacker probing sequential ids learns which ones are real and roughly how large your dataset is. Return the same shape, same extensions.code, and similar timing for both — null or an identical NOT_FOUND is usually the right answer for cross-tenant reads. Reserve a distinguishable error for records the caller genuinely owns.

Listing endpoints. Single-record resolvers get the famous audit; invoices(first: 50) written as SELECT * FROM invoices ORDER BY created_at LIMIT 50 hands over a stranger's page. Every list, connection, search, and aggregate needs the same predicate — aggregates especially, since count() across tenants leaks business volume even when individual rows are filtered.

Shared loader caches. A batch query with a tenant filter is still wrong if its results are memoized under a bare id across requests — the hit skips the filtered query entirely. Scope keys and instances per request; the mechanics are in request-scoped loaders.

Ownership checks belong with role-based field access: the directive or helper decides whether to look, and the tenant predicate decides what the lookup can see. Together they give the property an interviewer will ask you to state precisely: a caller can only ever read rows their tenant owns, and cannot tell which ids outside their tenant exist.

A compact audit for a new feature: find every statement touching tenant tables, confirm each has a tenant predicate, confirm list endpoints are filtered before LIMIT, and confirm the error response for a foreign id matches the response for a missing one.

Practice Problems

0 / 2 solved
Scope a Query to the Caller's Tenant
Tenant Isolation

Given a resolver that reads an invoice by id and a list resolver that pages invoices, rewrite both so only the caller's tenant rows are reachable: the single read must return null for a foreign id, and the page must contain only caller-tenant rows with the limit applied after filtering.

Example:

Input: ctx.viewer.tenantId = "acme"; invoice id 42 belongs to "globex"; invoices table has 10 globex + 3 acme rows

Output: invoice(42) → null; invoices(first: 50) → the 3 acme rows only

The predicate runs inside SQL, so globex's row is never read for an acme caller and no error distinguishes id 42 from a nonexistent id; the list applies tenant_id before ORDER BY/LIMIT, so the page cannot be filled with another tenant's rows.

Optimal Solution — O(log n) with an index on (tenant_id, id) time, O(k) for k rows returned space

Push both the ownership predicate and the tenant predicate into SQL, return null on a miss for single reads, and filter before ordering and limiting for lists

const resolvers = {
  Query: {
    invoice: (_, { id }, ctx) =>
      db.oneOrNone(
        'SELECT id, total, status FROM invoices ' +
          'WHERE id = $1 AND tenant_id = $2',
        [id, ctx.viewer.tenantId]        // verified claim, not a client argument
      ),                                  // foreign id -> null, same as missing id

    invoices: (_, { first, after }, ctx) =>
      db.many(
        'SELECT id, total, status FROM invoices ' +
          'WHERE tenant_id = $1 AND created_at < $2 ' +
          'ORDER BY created_at DESC LIMIT $3',
        [ctx.viewer.tenantId, after ?? new Date(), first]   // filter before limit
      ),
  },
};

// loader variant: scope lives in the statement and the cache key
const invoiceLoader = new DataLoader(
  async (ids) => {
    const rows = await db.query(
      'SELECT * FROM invoices WHERE tenant_id = $1 AND id = ANY($2)',
      [viewer.tenantId, ids]
    );
    const byId = new Map(rows.map((r) => [r.id, r]));
    return ids.map((id) => byId.get(id) ?? null);
  },
  { cacheKeyFn: (id) => viewer.tenantId + ':' + id }
);

Edge Cases:

  • Foreign id and nonexistent id must produce byte-identical responses
  • Empty result page: return [] rather than an error that reveals the filter rejected rows
  • Background job with no viewer: set tenant explicitly per unit of work, never run unscoped
Audit a Schema for Unscoped Data Paths
Tenant Leak Review

A GraphQL API has four read paths: single record by id, paginated list, full-text search, and a count aggregation. Two are scoped and two are not. Identify which leak and state what an attacker observes from each.

Example:

Input: single record: WHERE id=$1 AND tenant_id=$2 (scoped) | list: SELECT * ORDER BY created_at LIMIT 50 (unscoped) | search: WHERE to_tsvector(q) AND tenant_id (scoped) | count: SELECT count(*) FROM orders (unscoped)

Output: The list returns another tenant's 50 rows; the count reveals total cross-tenant order volume

Both leaks are invisible in single-record tests because that path is correct. The list hands over foreign rows directly, while the aggregate leaks only a number — enough to learn a competitor's volume without reading a single row.

Optimal Solution — O(n) scan → O(log n + k) with index on (tenant_id, created_at) time, O(k) space

Add the tenant predicate to every statement before any limiting, ranking, or aggregating step, and back the audit with a test that queries each path from a second tenant

-- leak 1: list paginates without scope
-- before
SELECT * FROM orders ORDER BY created_at DESC LIMIT 50;
-- after
SELECT * FROM orders
 WHERE tenant_id = $1
 ORDER BY created_at DESC
 LIMIT $2;

-- leak 2: aggregate spans tenants
-- before
SELECT count(*) AS total FROM orders;
-- after
SELECT count(*) AS total FROM orders
 WHERE tenant_id = $1;

-- regression test shape (per path)
async function returnsNoForeignRows(path, ctxAcme) {
  const res = await execute({ query: path, contextValue: ctxAcme });
  const rows = flatten(res.data);
  expect(rows.every((r) => r.tenantId === 'acme')).toBe(true);
}

Edge Cases:

  • Search ranking that scores across tenants before filtering — filter in the WHERE clause, not after ordering
  • COUNT with no rows for the tenant: return 0, not a fallback that queries globally
  • Cursor pagination: the cursor must not encode another tenant's row position

Quiz

1. Where must the tenant filter be applied when a resolver reads a record by id?

Question 1 options

2. What does PostgreSQL row-level security add beneath application-level filters?

Question 2 options

3. Why can returning FORBIDDEN for another tenant's id still be a vulnerability?

Question 3 options

Flashcards

Question

Tenant predicate rule

Answer

Every statement touching tenant tables includes a tenant filter sourced from verified claims — lists and aggregates included.

Question

Fetch-then-check

Answer

Filtering after the read still moved the row out of the DB and repeats at every call site; put the predicate in SQL instead.

Question

Row-level security

Answer

Database policy that applies the tenant predicate automatically; needs SET LOCAL per transaction and safe pool reset, and excludes BYPASSRLS roles.

Question

Enumeration oracle

Answer

Different responses for not-yours vs not-found confirm which ids exist — respond identically for both when ids are guessable.

Question

Loader scope in multi-tenancy

Answer

Batch queries must filter by tenant and cache keys must include it, or a cross-request cache hit skips the filter.

Revision Notes

Key Takeaways

  • 1. Tenant identity comes from verified claims and must appear in the SQL predicate, not in a post-fetch comparison
  • 2. RLS is the safety net for filters you have not written yet — but pooled connections must not carry scope across borrowers
  • 3. Isolation includes silence: identical answers for foreign and missing records, filtered lists, and scoped loader caches

Interview Tips

  • • State the invariant in one sentence — "a caller reads only rows whose tenant_id equals their verified claim" — then show where it is enforced
  • • Be ready to discuss the pooling trap with RLS; it signals you have operated the thing, not just read about it

Cheat Sheet

Multi-Tenant Scoping Cheat Sheet

Invariant: WHERE id = $1 AND tenant_id = <verified claim> on every read; same predicate on every list and aggregate

-- application filter (always)
SELECT * FROM invoices WHERE id = $1 AND tenant_id = $2;

-- second layer (Postgres RLS)
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.current_tenant_id', true));
-- in the request transaction:
BEGIN; SET LOCAL app.current_tenant_id = 'acme'; ...; COMMIT;
-- release: RESET ALL before the connection returns to the pool

Never: client-supplied tenantId · fetch-then-check · bare id lookups · unscoped LIMIT 50 lists

Leak checklist

  • foreign id vs missing id → identical response
  • loader cache key includes tenant + instance is per request
  • aggregates filtered, or volume leaks
  • DB role is not table owner and lacks BYPASSRLS

Continue Learning