Skip to content

Strapi 5 Multi‑Tenant Isolation: Middleware, RLS & Document Service

Learn how to enforce true multi‑tenant data isolation in Strapi 5 by adding a tenant field, creating tenant‑aware middleware, enabling PostgreSQL row‑level security, and using the Document Service API for safe queries.

By the Revisionary team · · 4 min read

a close up of a rack of computer equipment
Photo by Tyler on Unsplash

Strapi 5 does not ship with built‑in multi‑tenant isolation, but you can achieve true data separation by combining a tenant identifier on every record, tenant‑aware middleware that injects the identifier into every Document Service call, and PostgreSQL row‑level security (RLS) to enforce the rule at the database level.

1. Prerequisites

  • Strapi 5 (Node 18‑24) with a PostgreSQL database.
  • Basic familiarity with Strapi plugins, policies, and the Document Service API.
  • Access to the database with a superuser role to create RLS policies.

2. Add a tenant field to users and content types

The first step is to make the tenant identifier part of the data model. For a classic SaaS scenario you usually store the tenant ID on the authenticated user and propagate it to every piece of content they create.

2.1 Extend the User model

Create a new component tenant (type integer) or a relation to a tenant collection if you need richer metadata. Here we keep it simple and add a numeric column.

js
// src/api/user/content-types/user/schema.json
{
  "kind": "collectionType",
  "collectionName": "users-permissions_user",
  "info": { "singularName": "user", "pluralName": "users", "displayName": "User" },
  "options": { "draftAndPublish": false },
  "attributes": {
    "username": { "type": "string", "unique": true },
    "email": { "type": "email", "unique": true },
    "password": { "type": "password" },
    "tenant": { "type": "integer", "required": true }
  }
}

2.2 Add tenant to every content type that must be isolated

Repeat the same addition in each collection you want to scope. Example for a project type:

js
// src/api/project/content-types/project/schema.json
{
  "kind": "collectionType",
  "collectionName": "projects",
  "info": { "singularName": "project", "pluralName": "projects", "displayName": "Project" },
  "options": { "draftAndPublish": true },
  "attributes": {
    "title": { "type": "string" },
    "description": { "type": "text" },
    "tenant": { "type": "integer", "required": true }
  }
}

3. Create a tenant‑aware policy

Policies run after authentication and can reject a request if the user tries to access data that belongs to another tenant.

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

  // The tenant ID we expect – can come from query, body or URL param
  const tenantId =
    ctx.request.body?.tenant ||
    ctx.request.query?.tenant ||
    ctx.params?.tenantId;

  // If the request does not explicitly provide a tenant, fall back to the user's tenant
  const effectiveTenant = tenantId ?? user.tenant;

  if (Number(effectiveTenant) !== Number(user.tenant)) {
    return ctx.forbidden('You are not a member of this tenant');
  }

  // Attach the resolved tenant to the context for later middleware use
  ctx.state.tenant = effectiveTenant;
  return true;
};

Register the policy in config/policies.js and attach it to routes that need protection.

4. Global middleware that injects the tenant filter

Strapi 5’s Document Service (strapi.documents) is the recommended way to interact with content. A global middleware can automatically add a { tenant: ctx.state.tenant } filter to every read operation and set the field on creates/updates.

js
// src/middlewares/tenant-filter/index.js
module.exports = (config, { strapi }) => {
  return async (ctx, next) => {
    // Ensure the tenant policy ran first
    if (!ctx.state.tenant) {
      return ctx.unauthorized('Tenant not resolved');
    }
    const tenantId = ctx.state.tenant;

    // Wrap the Document Service methods we care about
    const originalUse = strapi.documents.use.bind(strapi.documents);
    strapi.documents.use = (serviceName) => {
      const service = originalUse(serviceName);
      const wrap = (method) => async (params = {}) => {
        // Inject tenant filter for read‑only methods
        if (['findMany', 'findOne', 'count'].includes(method)) {
          params.filters = params.filters || {};
          params.filters.tenant = tenantId;
        }
        // Set tenant on write methods
        if (['create', 'update', 'delete'].includes(method)) {
          params.data = params.data || {};
          params.data.tenant = tenantId;
        }
        return servicemethod;
      };
      return {
        ...service,
        findMany: wrap('findMany'),
        findOne:  wrap('findOne'),
        count:    wrap('count'),
        create:   wrap('create'),
        update:   wrap('update'),
        delete:   wrap('delete'),
        // keep other methods unchanged (publish, unpublish, etc.)
        publish:   service.publish,
        unpublish: service.unpublish,
        discardDraft: service.discardDraft,
      };
    };
    await next();
  };
};

Add the middleware to config/middlewares.js after the policy:

js
module.exports = [
  'strapi::errors',
  'strapi::security',
  'strapi::cors',
  'strapi::poweredBy',
  'strapi::logger',
  'strapi::query',
  'strapi::body',
  'strapi::session',
  'strapi::favicon',
  'strapi::public',
  // Custom middlewares
  './src/middlewares/tenant-filter',
];

Now every Document Service call automatically respects the tenant context.

5. Enforce isolation at the database level with PostgreSQL RLS

Application‑level checks are helpful, but a determined attacker could bypass them by calling the underlying SQL directly. PostgreSQL RLS ensures that even if the query is crafted manually, rows outside the tenant are invisible.

5.1 Create a session variable per request

We need to set app.current_tenant for each connection. Extend the same middleware to run a raw query before the request is processed:

js
// src/middlewares/set-rls-tenant/index.js
module.exports = (config, { strapi }) => {
  return async (ctx, next) => {
    const tenantId = ctx.state.tenant;
    if (!tenantId) return ctx.unauthorized('Tenant missing');

    // Use a transaction so the setting lives only for this request
    await strapi.db.connection.transaction(async (trx) => {
      await trx.raw('SET LOCAL app.current_tenant = ?', [tenantId]);
      // Store the transaction on context so later services use it
      ctx.state.trx = trx;
      await next();
    });
  };
};

Register this middleware before the tenant‑filter middleware so the variable is available when the Document Service runs.

5.2 Create RLS policies for each tenant‑scoped table

Connect to your PostgreSQL instance as a superuser and run:

sql
-- Enable RLS on the table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Policy that allows a row only when its tenant matches the session variable
CREATE POLICY tenant_isolation ON projects
  USING (tenant = current_setting('app.current_tenant')::int);

Repeat the ALTER TABLE … ENABLE ROW LEVEL SECURITY and CREATE POLICY statements for every table that contains a tenant column.

5.3 Verify the policy

From psql, set the session variable and try a query:

sql
SET app.current_tenant = 42;
SELECT id, title FROM projects;  -- Returns only rows where tenant = 42

If the variable is not set, PostgreSQL will raise an error, preventing accidental full‑table scans.

diagram
Photo by Shubham Dhage on Unsplash

6. Using the Document Service with tenant filtering

Because the middleware already adds the filter, most controller code can stay unchanged. Here is a typical controller that lists projects for the current tenant:

js
// src/api/project/controllers/project.js
module.exports = {
  async find(ctx) {
    // No need to pass tenant filter manually – middleware adds it
    const projects = await strapi.documents('api::project.project').findMany();
    ctx.body = projects;
  },

  async create(ctx) {
    const data = ctx.request.body;
    const project = await strapi.documents('api::project.project').create({ data });
    ctx.body = project;
  },
};

If you need a custom filter in addition to the tenant, simply add it:

js
const projects = await strapi.documents('api::project.project').findMany({
  filters: { status: 'active' } // tenant filter is merged automatically
});

6.1 Publishing with Draft & Publish

When Draft & Publish is enabled, the Document Service also exposes publish, unpublish and discardDraft. The middleware does not interfere; the tenant stays attached because the update path is used internally.

js
await strapi.documents('api::project.project').publish({
  documentId: '640c5f1e2f8b4a001c8f2d3a'
});

7. Testing the isolation

  1. Create two users belonging to tenant 1 and tenant 2.
  2. Log in as each user and call the /projects endpoint.
  3. Verify that each user only sees their own records.
  4. Attempt a direct request to /projects/:id with an ID that belongs to the other tenant – the policy should return 403 Forbidden.
  5. Connect to the DB with a superuser, unset the session variable, and try a raw SELECT. PostgreSQL should reject the query because the app.current_tenant setting is missing.

8. Optional: Separate schemas per tenant

If you prefer absolute physical isolation, you can create a distinct PostgreSQL schema for each tenant and set search_path in the same middleware. The RLS approach is usually sufficient and keeps the number of tables manageable, but the schema strategy can be combined with it for compliance‑driven environments.

9. When to use Revisionary

If you also need versioning, audit trails, or a UI to compare changes across tenants, the Revisionary plugin adds field‑level history, a trash, and an audit log. It works on top of the same tenant‑aware models, so you can enable it after the isolation layer is in place.

Conclusion

Strapi 5 does not ship with built‑in multi‑tenant isolation, but a combination of a tenant column, a tenant‑aware policy, global middleware that injects the tenant into every Document Service call, and PostgreSQL row‑level security gives you a robust, defense‑in‑depth solution. The approach keeps the API surface clean, leverages Strapi’s new Document Service, and guarantees that even raw SQL cannot leak data across tenants.


Key take‑aways

  • Add a required tenant integer field to users and every content type you want to scope.
  • Use a policy to reject cross‑tenant requests and expose ctx.state.tenant.
  • Global middleware wraps the Document Service, automatically filtering reads and stamping writes.
  • Enable PostgreSQL RLS and set a per‑request session variable to enforce isolation at the DB level.
  • The same pattern works with Draft & Publish, custom filters, and can be combined with the Revisionary plugin for versioning.

With these steps you have a production‑ready, true multi‑tenant Strapi 5 installation that scales safely across many customers.

Tagsstrapimulti-tenantmiddlewarepostgresqlrow-level securitydocument service

Sources

  1. Multi‑tenancy in Strapi – a comprehensive guide
  2. How to build a pseudo multi‑tenant application in Strapi
  3. Strapi backend customization – middlewares
  4. Document Service API – Strapi 5
  5. Strapi backend customization – policies
  6. PostgreSQL row‑level security documentation (Strapi example)

The Revisionary team

Developers who build Revisionary, a version history plugin for Strapi 5. We write about Strapi, web development, AI and application security.

This article was written with the help of AI.

All articles