Multi-Tenancy

Build multi-tenant SaaS APIs where every organization only ever sees its own rows.

Multi-Tenancy

Build multi-tenant SaaS APIs where every organization only ever sees its own rows.

With multi-tenancy on, every tenant-scoped resource is filtered to the caller's tenant on every route (list, get, update, delete, restore, bulk, aggregate, both sides of M2M, ?include=), the tenant column is stamped on create, and it can never be changed through a request body.

The tenant comes from the server, never the client. auto-api does not read a tenant from a header or a query parameter. And it fails closed: a request to a scoped resource without an active tenant is refused.

Quick start

1. Give scoped tables a tenant column

export const posts = sqliteTable('posts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  title: text('title').notNull(),
  organizationId: text('organization_id').notNull().references(() => organizations.id, { onDelete: 'cascade' }),
})

2. Turn it on

// nuxt.config.ts
export default defineNuxtConfig({
  autoApi: {
    multiTenancy: {
      enabled: true,
      tenantIdField: 'organizationId', // column on scoped tables (default)
      excludedResources: ['countries'], // global tables that happen to have the column
    },
  },
})

3. Put the active tenant on the user

// server/plugins/auth.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('request', async (event) => {
    const session = await getSession(event)
    if (session) {
      event.context.user = {
        id: session.user.id,
        organizationId: session.activeOrganizationId, // ← the active tenant
      }
    }
  })
})

With the better-auth plugin this is done for you: it maps the session's activeOrganizationId onto ctx.user.organizationId.

That's it:

GET  /api/posts                   → only the caller's organization
POST /api/posts  { "title": "…" } → organizationId stamped by the server
GET  /api/posts/<another org's id> → 404

Configuration

OptionDefaultDescription
enabledfalseTurn multi-tenancy on
tenantIdField'organizationId'Column (property key) that holds the tenant id on scoped tables
userTenantFieldtenantIdFieldProperty of ctx.user that holds the caller's active tenant
scopedResources'*''*' scopes every resource whose table has the tenant column; or a list of resource names
excludedResources[]Resources never scoped

A resource is scoped when multi-tenancy is on, it is not excluded, it is in scopedResources (or that is '*'), and its table has the tenant column. Tables without the column are never scoped.

Where the tenant comes from

In order — the first one that is set wins:

  1. ctx.tenant set by a context extender (custom resolution, cross-tenant operators, org API tokens)
  2. event.context.tenantId set by your own server middleware
  3. ctx.user[userTenantField] — ctx.user.organizationId by default

null, undefined and '' mean "no tenant". A scoped resource then answers 403 (401 when anonymous), and scoped rows reached indirectly (includes, M2M) are filtered to none.

Removed options.getTenantId, allowCrossTenantAccess and requireTenant were removed: they were functions/flags read through runtimeConfig, which is serialized — the functions never reached the server, and resolution silently fell back to a client-supplied x-tenant-id header. Setting any of them now fails the build with a pointer here.

Custom resolution

From a subdomain

Set event.context.tenantId in your own server middleware, after you have verified the caller belongs to it:

// server/middleware/tenant.ts
export default defineEventHandler(async (event) => {
  const subdomain = getRequestHost(event).split('.')[0]
  const org = await findOrganizationBySlug(subdomain)
  const user = event.context.user
  if (org && user && await isMember(user.id, org.id)) {
    event.context.tenantId = org.id
  }
})

With a context extender

A context extender runs inside the auto-api pipeline, after authentication, and can set ctx.tenant directly:

// server/plugins/tenant.ts
import { addContextExtender } from '@websideproject/nuxt-auto-api/plugins'

export default defineNitroPlugin(() => {
  addContextExtender(async (ctx) => {
    if (!ctx.user) return
    const orgId = await resolveActiveOrg(ctx.user.id) // your logic
    if (orgId) ctx.tenant = { id: orgId, field: 'organizationId', canAccessAllTenants: false }
  })
})

Cross-tenant operators

Set canAccessAllTenants: true for staff who must see every tenant. Scoping is then not applied, and they may set the tenant column explicitly on writes:

addContextExtender((ctx) => {
  if (ctx.user?.role === 'superadmin') {
    ctx.tenant = { id: ctx.user.organizationId ?? '', field: 'organizationId', canAccessAllTenants: true }
  }
})

Roles per organization

Tenancy decides which rows; permissions decide what the caller may do with them. A common setup keeps the caller's role in the active organization on the user and gates operations on it:

// modules/blog/auth.ts
import type { ResourceAuthConfig } from '@websideproject/nuxt-auto-api'

const orgRole = (ctx: any) => ctx.user?.orgRole as 'owner' | 'admin' | 'member' | 'viewer' | undefined
const atLeastMember = (ctx: any) => ['owner', 'admin', 'member'].includes(orgRole(ctx) ?? '')
const isOrgAdmin = (ctx: any) => ['owner', 'admin'].includes(orgRole(ctx) ?? '')

export const postsAuth: ResourceAuthConfig = {
  permissions: {
    read: ctx => !!orgRole(ctx), // any member of the active org
    create: atLeastMember,
    update: atLeastMember,
    delete: isOrgAdmin,
  },
  // Authors edit their own posts; org admins edit any post in the org.
  objectLevel: (post, ctx) =>
    ctx.operation === 'list' || ctx.operation === 'get' || post.authorId === ctx.user?.id || isOrgAdmin(ctx),
}

Tenant scoping is applied before objectLevel, so a post from another organization never reaches it.

Organization API tokens

The API token plugin binds a token to an organization with orgField. A request authenticated with such a token gets ctx.tenant set to the token's organization (not cross-tenant), so it can only ever touch that organization's rows:

// server/autoapi-plugins.ts
import { createApiTokenPlugin } from '@websideproject/nuxt-auto-api/plugins'

export default [
  createApiTokenPlugin({
    resources: {
      apiKeys: {
        userRelation: { field: 'userId', resource: 'users' },
        orgField: 'organizationId', // ← tokens are scoped to this org
        scopeField: 'scopes',
        expiresField: 'expiresAt',
        lastUsedField: 'lastUsedAt',
      },
    },
    auth: { enabled: true, header: 'Authorization', prefix: 'Bearer', tokenPrefix: 'sk_' },
  }),
]

GET /api/_token/introspect returns the calling token's resource, id, name, scopes, organization and expiry.

Testing

Assert isolation through the real routes — the package's test/security.test.ts does this with a fixture app:

it('lists and gets only the caller\'s tenant', async () => {
  const list = await $fetch('/api/posts', { headers: asUser('alice') }) // alice → org_a
  expect(list.data.every((p: any) => p.organizationId === 'org_a')).toBe(true)
  await expect($fetch(`/api/posts/${orgBPost.id}`, { headers: asUser('alice') })).rejects.toMatchObject({ statusCode: 404 })
})

it('ignores a client-supplied tenant', async () => {
  const res = await fetch('/api/posts', { headers: { ...asUser('no-org-user'), 'x-tenant-id': 'org_b' } })
  expect(res.status).toBe(403)
})

it('cannot move a row to another tenant', async () => {
  await $fetch(`/api/posts/${post.id}`, { method: 'PATCH', body: { organizationId: 'org_b' }, headers: asUser('alice') })
  expect((await $fetch(`/api/posts/${post.id}`, { headers: asUser('alice') })).data.organizationId).toBe('org_a')
})

Troubleshooting

Every request to a resource answers 403 "An active tenant is required" — the caller has no active tenant: check ctx.user.organizationId (or your userTenantField), or set event.context.tenantId / ctx.tenant.

A global table is being scoped — add it to excludedResources (it has the tenant column).

A cross-tenant admin sees only one tenant — set canAccessAllTenants: true on ctx.tenant from a context extender.

See also

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.