~/posts/multi-tenant-isolation-security-boundary

multi-tenant isolation is a security boundary, not a query filter

a payload multi-tenant saas, the privilege-escalation hole hiding in the plugin defaults, and proving isolation with real integration tests

i’m building a small multi-tenant cms for local businesses — a barbershop, a physio, a bakery each get a little site they can edit themselves. one payload backend, one postgres, many tenants. the whole promise is that the bakery’s editor logs in and sees the bakery. not the barbershop. never the barbershop.

so during a pre-launch hardening pass i did the thing i’ve learned to do: i stopped admiring the isolation and started attacking it. i logged in as a low-privilege editor — scoped to exactly one tenant — and sent one request to edit my own user account. not someone else’s. mine. i just added a second tenant to the list of tenants i belong to.

it went through. 200. and on the next request i could read, edit and delete another business’s pages as if they were my own.

nobody broke in. i handed myself the keys through the front door, using an endpoint that exists specifically so users can edit themselves. an isolation boundary you can widen with a PATCH to your own profile isn’t a boundary. it’s a default you forgot to close.

the setup: one plugin, many scoped collections

the stack is next.js 15 and payload cms 3, with @payloadcms/plugin-multi-tenant doing the heavy lifting. you hand the plugin a map of collections and it adds a tenant relationship field to each one, then filters every query by the tenants the current user belongs to:

payload.config.ts

ts
multiTenantPlugin({
  tenantsSlug: 'tenants',
  collections: {
    pages: {},
    'blog-posts': {},
    services: {},
    'team-members': {},
    testimonials: {},
    media: {},
    'form-submissions': {},
    'tenant-config': {},
  },
  userHasAccessToAllTenants: (user) =>
    (user as User | null)?.role === 'admin',
  // ...
}),

userHasAccessToAllTenants makes admins the super-user; everyone else is pinned to their assignments. two collections are deliberately not in that map — tenants (the root, it isn’t scoped to itself) and users (the global auth collection every business’s editor lives in). users get their own access rules:

src/collections/Users.ts

ts
access: {
  create: isAdmin,
  delete: isAdmin,
  read: isAdminOrSelf,
  update: isAdminOrSelf,
},

that isAdminOrSelf is the elegant part of payload’s model. an access function ↗ returns true, false, or a Where query — and payload runs the query as a filter:

src/access/index.ts

ts
export const isAdminOrSelf: Access = ({ req: { user } }) => {
  if (user?.role === 'admin') return true;
  return { id: { equals: user?.id } };
};

admins get true. everyone else gets a query that pins the operation to their own row. an editor can update exactly one user: themselves. which is exactly right — they should be able to change their own name and password. the docs put it plainly: “return a Query to limit the Documents to only those that match the constraint.”

the hole: the array the plugin quietly hangs off your user

here’s the part i didn’t look at hard enough. users isn’t in the plugin’s collection map, but the plugin still touches it — it hangs a tenants array off every user, because that array is how it knows which tenants you belong to. my own access code reads it:

src/access/index.ts

ts
export const isAdminOrTenantEditor: Access = ({ req: { user } }) => {
  if (user?.role === 'admin') return true;
  if (!user) return false;
  const ids = getUserTenantIds(user as User);
  return ids.length ? { tenant: { in: ids } } : false;
};

there it is, the whole isolation mechanism: read the user’s tenants, return { tenant: { in: ids } }, let payload filter. now stack up what an editor is actually allowed to do:

  • they can update their own user (isAdminOrSelf returns true for self), and
  • the plugin’s tenants array, unless you say otherwise, ships with no access rule of its own — and a field with no rule inherits the document’s, which is isAdminOrSelf.

so an editor can write their own tenants array. a PATCH /api/users/<my-own-id> with a body of { "tenants": [{ "tenant": 1 }, { "tenant": 2 }] } appends tenant 2. getUserTenantIds now returns [1, 2], and isAdminOrTenantEditor cheerfully hands back { tenant: { in: [1, 2] } } — a filter that now includes a tenant i was never granted.

and note what the filter did: exactly its job. it faithfully scoped me to my tenants. the problem was never the filter. the isolation lived in a query constraint, but the input to that constraint was attacker-writable. a Where clause is only as trustworthy as the data it reads, and i’d let the caller edit that data.

this is the same shape as the rate limiter whose bucket key the client got to choose : a control that keys on a value the attacker controls isn’t a control, it’s a suggestion. multi-tenant isolation is a security boundary, and a boundary has to be enforced where the data is written, not just where it’s read.

the fix, layer one: lock the array to admins

the plugin exposes exactly the knob for this — tenantsArrayField, with arrayFieldAccess and tenantFieldAccess. lock create and update to admins only:

payload.config.ts

ts
tenantsArrayField: {
  includeDefaultField: true,
  arrayFieldAccess: {
    create: ({ req }) => (req.user as User | null)?.role === 'admin',
    update: ({ req }) => (req.user as User | null)?.role === 'admin',
  },
  tenantFieldAccess: {
    create: ({ req }) => (req.user as User | null)?.role === 'admin',
    update: ({ req }) => (req.user as User | null)?.role === 'admin',
  },
},

now the tenants array — and the tenant relationship inside it — carries its own field-level rule ↗ : only an admin may write it. the editor can still PATCH themselves and change their name or password; the tenants field is simply stripped from the write. Where queries don’t enter into it — field access returns booleans only (“Field Access Control does not support returning Query constraints like Collection Access Control does”), which is fine, because may this user write this field at all is a yes/no question. deciding who can grant tenancy is now admin-only, which is where it always belonged.

the fix, layer two: a hook that doesn’t trust the plugin

field access closes it. but i’d just learned my lesson about trusting a default, so i put a second wall behind it. the exact way a plugin injects a field is a plugin-version dependency — if a future release reshapes how it adds that array, my one line of config might quietly stop applying. a beforeChange hook re-asserts the invariant in my own code:

src/collections/hooks/guardUserTenants.ts

ts
export const guardUserTenants: CollectionBeforeChangeHook = ({
  data,
  req,
  originalDoc,
  operation,
}) => {
  const actingUser = req.user as { role?: string } | null;
  if (actingUser?.role === 'admin') return data;
  if (operation === 'update' && originalDoc && 'tenants' in data) {
    (data as Record<string, unknown>).tenants = (
      originalDoc as Record<string, unknown>
    ).tenants;
  }
  return data;
};

read it plainly: admins pass untouched; for anyone else, if an update carries a tenants key, throw away the submitted value and restore the persisted one. a missing acting user is treated as non-admin — fail closed. it’s belt and suspenders, and i’m at peace with that. the config is the fix; the hook is the promise that the fix survives a dependency bump.

proving it: real payload, real postgres, no mocks

here’s the part i care about most, and the reason this post exists. i can unit-test isAdminOrSelf all day — feed it a fake user, assert it returns { id: { equals: 42 } } — and i do. but that proves my function is right. it proves nothing about whether payload actually applies that clause at query time, or whether the plugin’s field access actually drops the field on write. the bug lived in the gap between “my access function is correct” and “the framework enforces it end to end.” a mock can’t see that gap. only a real database can.

so the isolation suite boots a real payload against a throwaway postgres, wipes the schema, and seeds two tenants with one editor each:

src/__tests__/integration/global-setup.ts

ts
const editorA = await payload.create({
  collection: 'users',
  data: {
    email: 'editor-a@integration.test',
    password: editorPassword,
    role: 'editor',
    tenants: [{ tenant: tenantA.id }],
  },
  overrideAccess: true,
});

const editorB = await payload.create({
  collection: 'users',
  data: {
    email: 'editor-b@integration.test',
    password: editorPassword,
    role: 'editor',
    tenants: [{ tenant: tenantB.id }],
  },
  overrideAccess: true,
});

overrideAccess: true is seed god-mode — it bypasses access control so fixtures can exist. every real assertion runs with overrideAccess: false, as the editor. then the attack, encoded. this is the exact PATCH-yourself escalation from the top of the post, run against the live api as editor a:

src/__tests__/integration/hooks.test.ts

ts
it('prevents a non-admin from mutating their own tenants array', async () => {
  const updated = await payload.update({
    collection: 'users',
    id: fixtures.editorA.id,
    data: {
      tenants: [
        { tenant: fixtures.tenantA.id },
        // Attempted privilege escalation: tack on tenant B
        { tenant: fixtures.tenantB.id },
      ],
    },
    user: editorA,
    overrideAccess: false,
  });

  const tenantIds = (updated.tenants ?? []).map((t) =>
    typeof t.tenant === 'object'
      ? (t.tenant as { id: number }).id
      : (t.tenant as number)
  );
  expect(tenantIds).toEqual([fixtures.tenantA.id]);
  expect(tenantIds).not.toContain(fixtures.tenantB.id);
});

editor a submits both tenants; the write comes back with only their original one. the escalation i performed by hand is now a red test the day either wall comes down. and because isolation is the read-side of the same coin, the suite also proves the filter holds — editor b genuinely cannot touch tenant a’s rows:

src/__tests__/integration/tenant-isolation.test.ts

ts
it('editor B cannot update tenant A page', async () => {
  await expect(
    payload.update({
      collection: 'pages',
      id: fixtures.pageAId,
      data: { title: 'Hijacked' },
      user: editorB,
      overrideAccess: false,
    })
  ).rejects.toBeDefined();
});

real user, real row, real rejection from a real database. this is the drum i keep banging in why i reach for integration tests over mocks : the mock tells you your code does what you told it to; the container tells you the system does what you meant.

the honest part: one boundary, and only the doors i tried

the rule around here is that i don’t get to pretend. so:

this closes one boundary out of several. tenant isolation is not a single wall — it’s row-level authz (this post), object storage (can editor a fetch tenant b’s uploaded media straight from the bucket?), rate-limit and cost accounting per tenant, per-tenant secrets, noisy-neighbour resource limits, export and backup scoping. i hardened the row-level boundary and proved it. the others are their own boundaries with their own tests, and a couple aren’t written yet. “multi-tenant is secure” is never true; “this boundary is enforced and tested” can be.

framework-default trust is the actual bug, and it recurs. the tenants-array hole wasn’t a payload flaw — it was me assuming a default drew the line where i wanted it. that assumption is a factory, not a one-off. every plugin that adds a field, every framework that ships a permissive default, every “it just works” is a place to ask works for whom, exactly? i now read plugin-added fields as untrusted surface until i’ve pinned their access myself.

the test proves the doors i thought to try. the suite is real and i trust it, but it asserts isolation for the operations and collections i enumerated. it says nothing about the endpoint i didn’t think of, the collection i add next month and forget to scope, the graphql path that reaches the same data another way. a green suite is evidence of the boundaries i tested, not proof of the boundary in general. owasp files this whole family under broken access control ↗ , now the number-one web risk — worth reading precisely because most of its examples are one forgotten check, not a clever exploit.

lessons learned

  • isolation is a boundary you enforce on writes, not a filter you apply on reads. a Where clause that scopes an editor to their tenants is only as honest as the tenants list it reads — and if that list is attacker-writable, the filter faithfully enforces the attacker’s version. put the control where the data is set, and let the read-side filter be the convenience it actually is.
  • treat every framework default as a decision you haven’t made yet. the plugin hung a tenants array off my users with the most permissive access that could still function. that’s a reasonable default and a terrible assumption. name the authz rule for every field that gates access, out loud, in your own config.
  • defend at the layer, then prove it below your code. field access is the fix; the beforeChange hook is insurance against plugin drift; and none of it counts until a real user hits a real database and gets told no.
  • encode the attack, not just the guard. the strongest test in the suite is the one that literally performs the escalation and asserts it fails. it’ll go red the day someone “cleans up” the config or bumps the plugin. a test that reproduces the exploit is worth ten that assert the happy path.

next in this run of pre-launch hardening: the media boundary — whether an editor scoped to one tenant can fetch another tenant’s uploads straight from the bucket by guessing a url, and what signed, tenant-prefixed object keys cost to retrofit.

the code lives in a repo i’m not opening up yet, but i write these as i go — more at github.com/krtffl ↗ .

letters to the editor

no letters yet. the editor is patient.