Multi-Tenancy
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.
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
| Option | Default | Description |
|---|---|---|
enabled | false | Turn multi-tenancy on |
tenantIdField | 'organizationId' | Column (property key) that holds the tenant id on scoped tables |
userTenantField | tenantIdField | Property 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:
ctx.tenantset by a context extender (custom resolution, cross-tenant operators, org API tokens)event.context.tenantIdset by your own server middlewarectx.user[userTenantField]—ctx.user.organizationIdby 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.
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
- Authentication & Authorization
- Better-Auth Integration
- Plugin Catalog — API token plugin
Custom Endpoints
@websideproject/nuxt-auto-api provides two approaches for building custom server endpoints that integrate with the auto-api pipeline: createEndpoint() for full pipeline integration, and standalone helpers for lightweight use in regular Nitro handlers.
Schema Presets (Cross-engine)
nuxt-auto-api ships per-dialect column preset helpers so every table gets timestamps, softDelete, tenant, id, audit, and json columns with one import — and because auto-api's features are convention-driven (detect deletedAt by name; updatedAt self-manages via drizzle $onUpdate), importing a preset activates the feature automatically. No extra config.