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);
};
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'] },
},
],
};
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();
});
},
};
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);
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();
};
};
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);
});
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 (0)