The simplest multi-tenant auth architecture

One auth layer, tenant_id on every row, Postgres row-level security if you trust your ORM. Scales to most B2B SaaS.

· LoginWith team

B2B SaaS goes multi-tenant whether you plan for it or not. Your first customer is one org. Customer ten wants to invite 50 colleagues. Customer fifty wants workspaces that belong to different teams. Here’s an architecture that handles all of this without exotic infrastructure.

The shape

One database. One application. Every tenant is a row in a tenants table. Every non-lookup row has a tenant_id foreign key. Every query filters by tenant.

tenants (id, name, ...)
memberships (id, tenant_id, user_id, role)
users (id, email, ...)         -- global; users can be in multiple tenants
posts (id, tenant_id, ...)     -- tenant-scoped
comments (id, tenant_id, ...)  -- tenant-scoped

That’s the schema shape. Every B2B SaaS can start here.

The auth flow

  1. User signs in with email + password (or OAuth, SSO, whatever).
  2. Your auth layer returns the user’s identity.
  3. You look up memberships for that user — possibly multiple tenants.
  4. The user’s session stores both user_id and current_tenant_id.
  5. Every authenticated request has access to both.
  6. Every database query filters by current_tenant_id.

Tenant switching

Users in multiple tenants need a way to switch. UX patterns:

  • Subdomain: acme.yourapp.com vs contoso.yourapp.com. Clean separation, best for B2B enterprise.
  • Workspace picker: a dropdown in the app that switches the current tenant. Best for small teams who work across workspaces.
  • Both: subdomain default, with a picker for users in multiple tenants under the same subdomain.

Under the hood, switching tenants means updating the session’s current_tenant_id and refreshing the session cookie.

Enforcement: don’t trust application code

Every query needs to filter by tenant. In practice, every engineer eventually writes a query that forgets. That’s a cross-tenant data leak, which is a security bug, which can end a customer relationship.

Defense: Postgres Row-Level Security (RLS). For every table with a tenant_id:

ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON posts
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

Your request middleware sets the current tenant at the start of each request:

SET app.current_tenant = 'tenant-uuid';

Now any query — through your ORM, a new developer’s raw SQL, a forgotten filter — is automatically scoped. The DB refuses to return rows from other tenants. Defense in depth: application code does the filter, DB enforces it as a fallback.

What not to do (common traps)

Don’t use schema-per-tenant

Separate Postgres schemas per tenant sounds like cleaner isolation. In practice, it creates migration nightmares (applying the same migration to 500 schemas), operational overhead, and rarely provides the isolation benefit in question. Use it only if a specific compliance requirement demands it.

Don’t use database-per-tenant

Same problems, worse. Don’t start here.

Don’t let clients pass tenant_id in requests

// WRONG
app.get('/posts', (req) => {
  return db.query('SELECT * FROM posts WHERE tenant_id = ?', [req.query.tenant])
})

Clients can change req.query.tenant to any value. Derive the tenant from the session, always:

// RIGHT
app.get('/posts', (req) => {
  return db.query('SELECT * FROM posts WHERE tenant_id = ?', [req.session.tenant_id])
})

When to graduate

The one-DB, RLS-based approach scales to hundreds of tenants and millions of rows per tenant comfortably. You start hitting limits when:

  • A single tenant is 10× the size of others and disrupts shared-pool queries
  • A specific compliance requirement needs physical isolation
  • You’re selling a self-hosted option

At that point, schema-per-tenant or DB-per-tenant becomes necessary. But 95% of B2B SaaS never reaches that point. The simple model wins.

Want auth that just works?

Get started with LoginWith