DEV Community

Mukesh
Mukesh

Posted on

Multi-Tenant Content Isolation in Strapi: Scoping Every Query by Tenant Without Duplicating Content-Types

The Problem With "Just Add a Tenant Field"

Strapi has no built-in concept of a tenant. Teams building SaaS products on top of it tend to land on one of two approaches, and both have sharp edges.

The first is one Strapi instance (and one database) per customer. It's genuinely isolated, but it means running a fleet of deployments, migrating schema changes across all of them in lockstep, and paying for idle compute on every low-traffic tenant. The second is a shared instance where every content-type gets a tenant relation and every controller is expected to remember to filter by it. This works until someone adds a new controller, a custom route, or a find call inside a lifecycle hook and forgets the filter — at which point tenant A can read tenant B's data, and nothing in Strapi stops it.

The fix isn't picking one of these extremes. It's a shared instance where tenant scoping is enforced structurally — at the query layer, not by controller-author discipline — with a database-level backstop for the inevitable case where someone still forgets.

This walkthrough builds that on Strapi v5 with a Postgres database, but the pattern applies with minor adjustments to v4's Entity Service.

Layer 1: A Policy That Confirms Tenant Membership

Start with the boring, necessary layer: confirming the authenticated user actually belongs to the tenant they're claiming. Add a tenant relation to your user model and to every tenant-scoped content-type, then write a reusable policy:

// src/policies/is-tenant-member.js
module.exports = async (policyContext, config, { strapi }) => {
  const { state } = policyContext;
  const user = state.user;
  if (!user) return false;

  const requestedTenantId =
    policyContext.request.query.tenantId ||
    policyContext.request.body?.data?.tenant;

  if (!requestedTenantId) return false;

  const membership = await strapi.db.query('plugin::users-permissions.user').findOne({
    where: { id: user.id, tenant: requestedTenantId },
  });

  return Boolean(membership);
};
Enter fullscreen mode Exit fullscreen mode

Wire it into a route:

// src/api/project/routes/project.js
module.exports = {
  routes: [
    {
      method: 'GET',
      path: '/projects',
      handler: 'project.find',
      config: { policies: ['global::is-tenant-member'] },
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

This stops unauthenticated cross-tenant requests, but it does nothing about a controller that queries the wrong tenant's data on the authenticated user's behalf. That's a separate failure mode and needs a separate defense.

Layer 2: Auto-Inject the Filter at the Query Layer

The real leak risk isn't the route someone remembered to protect — it's the query someone forgot to scope. Instead of trusting every controller and lifecycle hook to add filters: { tenant: ctx.state.user.tenant } by hand, wrap the Document Service so tenant filtering happens automatically for every call, including ones written months from now by someone who's never read this article.

Strapi v5's Document Service supports middleware that wraps every findMany, findOne, create, update, and delete call:

// src/index.js
module.exports = {
  register({ strapi }) {
    const TENANT_SCOPED_UIDS = new Set([
      'api::project.project',
      'api::task.task',
      'api::invoice.invoice',
    ]);

    strapi.documents.use(async (context, next) => {
      if (!TENANT_SCOPED_UIDS.has(context.uid)) return next();

      const tenantId = strapi.requestContext.get()?.state?.user?.tenant;
      if (!tenantId) {
        throw new Error(`Tenant-scoped query on ${context.uid} with no tenant in context`);
      }

      if (['findMany', 'findFirst', 'count'].includes(context.action)) {
        context.params.filters = {
          $and: [context.params.filters || {}, { tenant: tenantId }],
        };
      }

      if (['create'].includes(context.action)) {
        context.params.data = { ...context.params.data, tenant: tenantId };
      }

      return next();
    });
  },
};
Enter fullscreen mode Exit fullscreen mode

The important design choice here is the explicit allowlist (TENANT_SCOPED_UIDS) rather than an implicit "scope everything with a tenant field" rule, and the hard throw when no tenant is present in context — silently skipping the filter is exactly the bug this middleware exists to prevent. A background job or admin-panel call that legitimately needs cross-tenant access should set an explicit context.params.tenant = 'all' escape hatch you check for, not rely on the middleware quietly no-op'ing.

strapi.requestContext (Strapi v5's AsyncLocalStorage-backed context) is what makes this work without threading tenantId through every function signature — it's populated once in a global middleware early in the request lifecycle and is readable anywhere downstream, including inside lifecycle hooks that don't have direct access to ctx.

Layer 3: Postgres Row-Level Security as the Backstop

Application-layer scoping is only as good as the code enforcing it, and someone will eventually add a raw strapi.db.query() call or a custom SQL query that bypasses the Document Service entirely. That's what RLS is for: even a query that skips your middleware still can't see rows outside its tenant, because Postgres enforces it at the row level.

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON projects
  USING (tenant_id = current_setting('app.current_tenant', true)::int);
Enter fullscreen mode Exit fullscreen mode

The hard part is getting app.current_tenant set correctly given that Strapi's Knex connection pool reuses connections across requests — a session variable set for one request could leak into the next request that grabs the same pooled connection. The fix is to set it per-transaction, not per-connection, using SET LOCAL inside a transaction wrapper that scopes every request:

// src/middlewares/rls-context.js
module.exports = (config, { strapi }) => {
  return async (ctx, next) => {
    const tenantId = ctx.state.user?.tenant;
    if (!tenantId) return next();

    const knex = strapi.db.connection;
    await knex.raw('SET LOCAL app.current_tenant = ?', [tenantId]);
    return next();
  };
};
Enter fullscreen mode Exit fullscreen mode

SET LOCAL is transaction-scoped and automatically resets at commit or rollback, so it can't bleed into the next pooled request the way a plain SET would. This does require each request that touches RLS-protected tables to run inside an explicit transaction — for a Strapi app already wrapping mutating requests in transactions for consistency, this is a small addition; for one that isn't, it's worth adding regardless of multi-tenancy, since RLS without transaction-scoped session variables is a subtle way to leak tenant B's rows into tenant A's response under connection pool pressure.

Proving It Actually Works

The test that matters isn't "tenant A can read their own data" — that's the easy path everyone tests. It's proving a forgotten policy still can't leak:

test('raw query bypassing the Document Service still respects tenant isolation', async () => {
  const tenantAProject = await createProject({ tenant: tenantA.id });

  // Deliberately skip strapi.documents and the middleware layer
  const rows = await strapi.db.connection.raw(
    'SET LOCAL app.current_tenant = ?; SELECT * FROM projects WHERE id = ?',
    [tenantB.id, tenantAProject.id]
  );

  expect(rows.rows).toHaveLength(0);
});
Enter fullscreen mode Exit fullscreen mode

If this test passes, RLS is doing its job independently of whether the application code remembered to filter — which is the entire point of a defense-in-depth layer.

When Not to Do This

Shared-instance multi-tenancy earns its complexity when tenants are numerous, similarly shaped, and don't need per-tenant schema customization. If a handful of enterprise customers need custom content-types, different Strapi plugins enabled, or contractual data-residency guarantees that require physical database separation, per-tenant instances stop being an ops annoyance and become the correct architecture. The layered approach here is for the common SaaS case: dozens to thousands of tenants sharing one schema, where the cost of per-tenant infrastructure would dwarf the engineering cost of getting isolation right once.

Top comments (2)

Collapse
 
cekuu35 profile image
Cenk KURTOĞLU

Layer 3 is the right instinct and the pooled-connection warning is one most write-ups skip. Two things will stop it working as written though, and both fail quietly, which is the worst way for a backstop to fail.

1. RLS does not apply to the table owner.

Postgres skips row-level security for a table's owner unless you force it. Strapi's Postgres user is almost always the owner of the tables it migrated, so ENABLE ROW LEVEL SECURITY alone leaves the policy inert for exactly the connection your app uses — the raw strapi.db.query() call you are defending against sails straight through.

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE  ROW LEVEL SECURITY;   -- the line that makes it real
Enter fullscreen mode Exit fullscreen mode

Worth checking rather than assuming:

select c.relname,
       pg_get_userbyid(c.relowner) as owner,
       c.relrowsecurity            as rls_on,
       c.relforcerowsecurity       as rls_forced
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r'
order by c.relforcerowsecurity, 1;
Enter fullscreen mode Exit fullscreen mode

If owner is your Strapi role and rls_forced is false, Layer 3 is decoration.

2. SET LOCAL outside a transaction does nothing — and the middleware never opens one.

SET LOCAL is transaction-scoped, which is exactly why you reached for it. But knex.raw(...) on the pool checks out an arbitrary connection, runs the statement with no surrounding transaction, and hands the connection back. Postgres treats SET LOCAL outside a transaction block as a no-op (it even warns), and the queries in next() may well land on a different pooled connection anyway.

The fix is to make the transaction the request scope and pass it down, rather than setting a variable and hoping:

module.exports = (config, { strapi }) => async (ctx, next) => {
  const tenantId = ctx.state.user?.tenant;
  if (!tenantId) return next();

  return strapi.db.connection.transaction(async (trx) => {
    await trx.raw('SET LOCAL app.current_tenant = ?', [tenantId]);
    ctx.state.trx = trx;          // every query in this request must use it
    await next();
  });
};
Enter fullscreen mode Exit fullscreen mode

The catch is the comment on that third line: any query that does not run on trx gets a different connection and therefore a different (unset) app.current_tenant.

And that failure is worth knowing the shape of. With the setting unset, current_setting('app.current_tenant', true) returns NULL, NULL::int is NULL, and tenant_id = NULL evaluates to NULL — so the policy matches nothing and the request returns zero rows. It fails closed, which is the good direction, but it looks like a data bug rather than a configuration one, and people usually "fix" it by removing the policy.

A test that catches both

Your bypass test is the right idea, but run it as the role your app actually connects with, and assert on a specific row rather than on a count:

begin;
select set_config('app.current_tenant', '1', true);
set local role strapi_app;          -- whatever Strapi authenticates as
select id, tenant_id from projects;  -- must contain only tenant 1
rollback;
Enter fullscreen mode Exit fullscreen mode

If tenant 2's rows appear there, it is the owner-bypass in point 1. If nothing appears at all, the setting is not reaching the connection, which is point 2.

Collapse
 
mukesh_13 profile image
Mukesh

Both absolutely right — FORCE is the line most write-ups skip, and SET LOCAL outside a transaction fails invisibly and looks like a data bug. The owner-bypass is usually discovered when Layer 3 mysteriously stops blocking raw queries, but your test case running as the app role catches both problems immediately. I'll add a section covering both pitfalls and that verification pattern.