Multi-Tenancy

This guide covers building multi-tenant SaaS applications with organization-level permissions, where users can have different roles across organizations.

Multi-Tenancy

This guide covers building multi-tenant SaaS applications with organization-level permissions, where users can have different roles across organizations.

Overview

Multi-tenancy enables:

  • Data isolation - Automatic filtering by organization
  • Organization-level permissions - User is admin in Org A, member in Org B
  • API token scoping - Tokens bound to organizations
  • Flexible tenant resolution - From user, header, subdomain, etc.
  • Admin bypass - Superadmins can access all organizations

Table of Contents

  1. Quick Start
  2. Schema Design
  3. Configuration
  4. Organization-Member Permissions
  5. Better-Auth Integration
  6. API Token Plugin
  7. Tenant Resolution Strategies
  8. Advanced Patterns
  9. Testing

Quick Start

1. Add Organization Field to Schema

// server/database/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
import { relations } from 'drizzle-orm'

export const organizations = sqliteTable('organizations', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  slug: text('slug').notNull().unique(),
  createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
})

export const posts = sqliteTable('posts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  title: text('title').notNull(),
  content: text('content'),
  userId: integer('user_id').notNull(),
  organizationId: text('organization_id')  // ← Multi-tenancy field
    .notNull()
    .references(() => organizations.id, { onDelete: 'cascade' }),
  createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
})

export const postsRelations = relations(posts, ({ one }) => ({
  organization: one(organizations, {
    fields: [posts.organizationId],
    references: [organizations.id],
  }),
  user: one(users, {
    fields: [posts.userId],
    references: [users.id],
  }),
}))

2. Enable Multi-Tenancy

// nuxt.config.ts
export default defineNuxtConfig({
  autoApi: {
    multiTenancy: {
      enabled: true,
      tenantIdField: 'organizationId',
      getTenantId: (event) => event.context.user?.organizationId,
    }
  }
})

3. Set User Context

// 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,
        email: session.user.email,
        organizationId: session.activeOrganizationId,  // ← Current org
      }
    }
  })
})

4. Done!

All operations are now automatically scoped to the user's organization:

# User in Org A
GET /api/posts
# Returns only posts where organizationId = 'org_a'

POST /api/posts
{ "title": "New Post", "content": "..." }
# Automatically sets organizationId = 'org_a'

Schema Design

Organization-Member Pattern

For users with different roles per organization, use a join table:

// Organizations
export const organizations = sqliteTable('organizations', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  slug: text('slug').notNull().unique(),
  plan: text('plan', { enum: ['free', 'pro', 'enterprise'] }).default('free'),
  createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
})

// Users (global, no org field)
export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  email: text('email').notNull().unique(),
  name: text('name'),
  createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
})

// Organization Members (role per org)
export const organizationMembers = sqliteTable('organization_members', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  userId: integer('user_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  organizationId: text('organization_id')
    .notNull()
    .references(() => organizations.id, { onDelete: 'cascade' }),
  role: text('role', { enum: ['owner', 'admin', 'member', 'viewer'] })
    .notNull()
    .default('member'),
  invitedAt: integer('invited_at', { mode: 'timestamp' }),
  joinedAt: integer('joined_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
}, (table) => ({
  // User can be member of org only once
  uniqueUserOrg: unique().on(table.userId, table.organizationId),
}))

// Relations
export const organizationMembersRelations = relations(organizationMembers, ({ one }) => ({
  user: one(users, {
    fields: [organizationMembers.userId],
    references: [users.id],
  }),
  organization: one(organizations, {
    fields: [organizationMembers.organizationId],
    references: [organizations.id],
  }),
}))

export const usersRelations = relations(users, ({ many }) => ({
  memberships: many(organizationMembers),
}))

export const organizationsRelations = relations(organizations, ({ many }) => ({
  members: many(organizationMembers),
  posts: many(posts),
}))

// Scoped Resources
export const posts = sqliteTable('posts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  title: text('title').notNull(),
  content: text('content'),
  authorId: integer('author_id').notNull(),  // Not FK to prevent cascade
  organizationId: text('organization_id')
    .notNull()
    .references(() => organizations.id, { onDelete: 'cascade' }),
  createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
})

Why This Pattern?

User belongs to multiple organizations - via organizationMembersDifferent role per organization - role field on membership ✅ Resources scoped to organization - organizationId on postsClean user switching - Change active organization without re-auth


Configuration

Basic Configuration

export default defineNuxtConfig({
  autoApi: {
    multiTenancy: {
      enabled: true,
      tenantIdField: 'organizationId',  // Field name in tables
      scopedResources: '*',              // All resources (default)
      excludedResources: [],             // Global resources
      requireTenant: true,               // Require tenant for all ops
      getTenantId: (event) => {
        return event.context.user?.organizationId
      },
      allowCrossTenantAccess: (user) => {
        return user?.role === 'superadmin'
      },
    }
  }
})

Configuration Reference

PropertyTypeDefaultDescription
enabledbooleanfalseEnable multi-tenancy
tenantIdFieldstring'organizationId'Column name for tenant ID
scopedResources'*' | string[]'*'Which resources to scope
excludedResourcesstring[][]Global resources (not scoped)
requireTenantbooleantrueRequire tenant for all operations
getTenantIdfunction-Extract tenant ID from request
allowCrossTenantAccessfunction-Check if user can access all orgs

Scoped vs Global Resources

multiTenancy: {
  enabled: true,

  // All resources except these are scoped
  excludedResources: [
    'users',        // Global user table
    'countries',    // Shared reference data
    'timezones',    // Shared reference data
  ],

  // Only these resources are scoped
  scopedResources: [
    'posts',
    'comments',
    'projects',
  ],
}

Organization-Member Permissions

Permission Helpers

Create helpers to check organization membership and role:

// server/utils/orgPermissions.ts
import type { H3Event } from 'h3'
import { db } from '../database/db'
import { organizationMembers } from '../database/schema'
import { and, eq } from 'drizzle-orm'

export type OrgRole = 'owner' | 'admin' | 'member' | 'viewer'

export async function getOrgMembership(
  userId: number,
  organizationId: string
): Promise<OrgRole | null> {
  const membership = await db.query.organizationMembers.findFirst({
    where: and(
      eq(organizationMembers.userId, userId),
      eq(organizationMembers.organizationId, organizationId)
    ),
  })

  return membership?.role ?? null
}

export async function hasOrgRole(
  userId: number,
  organizationId: string,
  requiredRoles: OrgRole | OrgRole[]
): Promise<boolean> {
  const role = await getOrgMembership(userId, organizationId)
  if (!role) return false

  const roles = Array.isArray(requiredRoles) ? requiredRoles : [requiredRoles]
  return roles.includes(role)
}

export async function isOrgOwner(userId: number, organizationId: string): Promise<boolean> {
  return hasOrgRole(userId, organizationId, 'owner')
}

export async function isOrgAdmin(userId: number, organizationId: string): Promise<boolean> {
  return hasOrgRole(userId, organizationId, ['owner', 'admin'])
}

// Role hierarchy check
const roleHierarchy: Record<OrgRole, number> = {
  owner: 4,
  admin: 3,
  member: 2,
  viewer: 1,
}

export async function hasMinimumRole(
  userId: number,
  organizationId: string,
  minimumRole: OrgRole
): Promise<boolean> {
  const userRole = await getOrgMembership(userId, organizationId)
  if (!userRole) return false

  return roleHierarchy[userRole] >= roleHierarchy[minimumRole]
}

Resource Permissions with Organization Roles

// modules/blog/auth.ts
import { hasMinimumRole, isOrgAdmin } from '~/server/utils/orgPermissions'

export const postsAuth = {
  async canList(user, context) {
    if (!user) return false

    // Must be member of the organization
    return hasMinimumRole(user.id, context.tenant.id, 'viewer')
  },

  async canCreate(user, context) {
    // Must be member or admin
    return hasMinimumRole(user.id, context.tenant.id, 'member')
  },

  async canUpdate(user, context) {
    // Check per-object (own posts or admin)
    return true  // Object-level check handles this
  },

  async canDelete(user, context) {
    // Only admins can delete
    return isOrgAdmin(user.id, context.tenant.id)
  },

  // Object-level: Can update own posts, or admin can update any
  async objectLevel(post, context) {
    if (!context.user) return false

    // Post author can edit
    if (post.authorId === context.user.id) return true

    // Org admin can edit any post in their org
    return isOrgAdmin(context.user.id, post.organizationId)
  },

  // SQL-level list filtering
  listFilter: (table, context) => {
    const { user } = context
    if (!user) return eq(table.id, -1)  // Return nothing

    // Viewers see only published, members+ see all
    return hasMinimumRole(user.id, context.tenant.id, 'member')
      ? undefined  // No filter (tenant isolation handles org boundary)
      : eq(table.published, true)
  },
}

Example: Different Permissions Per Org

// User Alice
const alice = {
  id: 1,
  email: 'alice@example.com',
  organizationId: 'org_a',  // Currently active org
}

// Alice's memberships
await db.insert(organizationMembers).values([
  { userId: 1, organizationId: 'org_a', role: 'admin' },   // Admin in Org A
  { userId: 1, organizationId: 'org_b', role: 'viewer' },  // Viewer in Org B
])

// In Org A context (alice.organizationId = 'org_a')
await canCreate(alice, { tenant: { id: 'org_a' } })   // ✅ true (admin)
await canDelete(alice, { tenant: { id: 'org_a' } })   // ✅ true (admin)

// If Alice switches to Org B (alice.organizationId = 'org_b')
await canCreate(alice, { tenant: { id: 'org_b' } })   // ❌ false (viewer)
await canDelete(alice, { tenant: { id: 'org_b' } })   // ❌ false (viewer)

Better-Auth Integration

Installation

npm install better-auth

Setup with Organization Plugin

// server/lib/auth.ts
import { betterAuth } from 'better-auth'
import { organization } from 'better-auth/plugins'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { db } from '../database/db'
import * as schema from '../database/schema'

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: 'sqlite',
    schema,
  }),

  plugins: [
    organization({
      // Organization plugin adds:
      // - organizations table
      // - members table with roles
      // - session.activeOrganizationId
      // - organization switching API
      roles: ['owner', 'admin', 'member', 'viewer'],
    }),
  ],

  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days
    updateAge: 60 * 60 * 24,      // Update every 24 hours
  },
})

Auth Plugin for Nuxt-Auto-API

// server/plugins/auth.ts
import { auth } from '../lib/auth'

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('request', async (event) => {
    // Get session from Better-Auth
    const session = await auth.api.getSession({
      headers: event.node.req.headers,
    })

    if (!session) return

    // Get active organization membership
    const activeMembership = session.user.organizationId
      ? await db.query.organizationMembers.findFirst({
          where: and(
            eq(organizationMembers.userId, session.user.id),
            eq(organizationMembers.organizationId, session.user.organizationId)
          ),
        })
      : null

    // Set user context with organization
    event.context.user = {
      id: session.user.id,
      email: session.user.email,
      name: session.user.name,
      organizationId: session.activeOrganizationId,  // Current org
      role: activeMembership?.role ?? 'viewer',      // Role in current org
    }

    // Set permissions array (for simple permission checks)
    event.context.permissions = activeMembership
      ? [activeMembership.role]
      : []
  })
})

Organization Switching

// Frontend: Switch active organization
async function switchOrganization(orgId: string) {
  await $fetch('/api/auth/organization/set-active', {
    method: 'POST',
    body: { organizationId: orgId },
  })

  // Refresh session
  await refreshNuxtData()

  // All subsequent API calls now use new org context
}

Better-Auth Organization Schema

// Better-Auth creates these tables automatically:
// - organizations: { id, name, slug, createdAt, ... }
// - members: { id, userId, organizationId, role, ... }
// - sessions: { ..., activeOrganizationId }

// You don't need to define them manually!

API Token Plugin with Organizations

Configuration

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

export default createApiTokenPlugin({
  resources: {
    apiKeys: {
      userRelation: {
        field: 'userId',
        resource: 'users',
      },
      orgField: 'organizationId',  // ← Org scoping for tokens
      scopeField: 'scopes',
      expiresField: 'expiresAt',
      lastUsedField: 'lastUsedAt',
      authEnabled: true,
    },
  },

  auth: {
    enabled: true,
    header: 'Authorization',
    prefix: 'Bearer',
    tokenPrefix: 'sk_',
  },

  mapUser: async (dbRow, db) => {
    // Fetch user's role in the token's organization
    const membership = await db.query.organizationMembers.findFirst({
      where: and(
        eq(organizationMembers.userId, dbRow.userId),
        eq(organizationMembers.organizationId, dbRow.organizationId)
      ),
    })

    return {
      id: dbRow.userId,
      email: dbRow.user?.email,
      organizationId: dbRow.organizationId,  // Token's org
      role: membership?.role ?? 'member',     // Role in that org
    }
  },
})

API Keys Schema

// modules/api-tokens/schema.ts
export const apiKeys = sqliteTable('api_keys', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  name: text('name').notNull(),
  key: text('key').notNull().unique(),  // SHA-256 hash
  userId: integer('user_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  organizationId: text('organization_id')  // ← Token scoped to org
    .notNull()
    .references(() => organizations.id, { onDelete: 'cascade' }),
  scopes: text('scopes', { mode: 'json' }).$type<string[]>(),
  expiresAt: integer('expires_at', { mode: 'timestamp' }),
  lastUsedAt: integer('last_used_at', { mode: 'timestamp' }),
  createdAt: integer('created_at', { mode: 'timestamp' })
    .notNull()
    .$defaultFn(() => new Date()),
})

Creating Organization-Scoped Tokens

// Backend: Create token endpoint
export default defineEventHandler(async (event) => {
  const user = event.context.user
  if (!user?.organizationId) {
    throw createError({ statusCode: 401, message: 'Unauthorized' })
  }

  // Generate token
  const rawToken = `sk_${randomBytes(32).toString('hex')}`
  const hashedKey = createHash('sha256').update(rawToken).digest('hex')

  // Store with org scope
  const apiKey = await db.insert(apiKeys).values({
    name: 'My API Token',
    key: hashedKey,
    userId: user.id,
    organizationId: user.organizationId,  // ← Bound to user's current org
    scopes: ['posts:read', 'posts:write'],
    expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year
  }).returning()

  // Return raw token ONCE (never stored)
  return {
    id: apiKey.id,
    token: rawToken,  // Show to user, they must save it
    name: apiKey.name,
    scopes: apiKey.scopes,
    expiresAt: apiKey.expiresAt,
  }
})

Using Organization-Scoped Tokens

# Token automatically sets organization context
curl -H "Authorization: Bearer sk_abc123..." \
  https://api.example.com/api/posts

# Returns only posts from token's organization
# Token cannot access other organizations

Token Behavior with Multi-Tenancy

When API token plugin detects orgField:

  1. Extracts organization from token record
  2. Sets tenant context automatically:
    context.tenant = {
      id: token.organizationId,
      field: 'organizationId',
      canAccessAllTenants: false,  // Tokens are single-org only
    }
    
  3. All operations scoped to token's organization
  4. Cannot bypass tenant isolation (even if user is admin in another org)

Token Introspection

GET /api/_token/introspect
Authorization: Bearer sk_abc123...

{
  "data": {
    "resource": "apiKeys",
    "id": 42,
    "name": "My API Token",
    "scopes": ["posts:read", "posts:write"],
    "organizationId": "org_a",
    "expiresAt": "2026-01-01T00:00:00Z",
    "lastUsedAt": "2026-02-08T10:30:00Z"
  }
}

Tenant Resolution Strategies

multiTenancy: {
  getTenantId: (event) => {
    return event.context.user?.organizationId
  }
}

Pros: Works with Better-Auth, simple Use case: Standard SaaS with user login

2. From Subdomain

multiTenancy: {
  getTenantId: (event) => {
    const host = event.node.req.headers.host
    if (!host) return null

    // Extract subdomain: acme.example.com → acme
    const subdomain = host.split('.')[0]
    if (subdomain === 'www' || subdomain === 'api') return null

    return subdomain
  }
}

Pros: Tenant-specific URLs Use case: acme.app.com, globex.app.com

3. From Header

multiTenancy: {
  getTenantId: (event) => {
    return getHeader(event, 'x-tenant-id')
  }
}

Pros: Flexible, works with any client Use case: Mobile apps, API integrations

4. From JWT Claim

multiTenancy: {
  getTenantId: (event) => {
    const token = getHeader(event, 'authorization')?.replace('Bearer ', '')
    if (!token) return null

    const decoded = decodeJWT(token)
    return decoded.organizationId
  }
}

Pros: Embedded in auth token Use case: External auth providers (Auth0, Clerk)

5. Hybrid Approach

multiTenancy: {
  getTenantId: async (event) => {
    // 1. Try user context (Better-Auth)
    if (event.context.user?.organizationId) {
      return event.context.user.organizationId
    }

    // 2. Try header (API clients)
    const headerTenant = getHeader(event, 'x-tenant-id')
    if (headerTenant) return headerTenant

    // 3. Try subdomain (multi-site)
    const host = event.node.req.headers.host
    if (host) {
      const subdomain = host.split('.')[0]
      if (subdomain && subdomain !== 'www') return subdomain
    }

    return null
  }
}

Advanced Patterns

Superadmin Bypass

multiTenancy: {
  allowCrossTenantAccess: (user) => {
    // Superadmin can access all organizations
    return user?.role === 'superadmin' || user?.permissions?.includes('cross_tenant_access')
  }
}

// Superadmin queries
GET /api/posts?organizationId=org_a  // Returns org_a posts
GET /api/posts?organizationId=org_b  // Returns org_b posts
GET /api/posts                        // Returns ALL posts (cross-tenant)

Organization Isolation Levels

// STRICT: Require tenant for all operations
multiTenancy: {
  enabled: true,
  requireTenant: true,  // 400 error if no tenant
}

// PERMISSIVE: Allow operations without tenant (returns global data)
multiTenancy: {
  enabled: true,
  requireTenant: false,  // Allow null tenant
}

Resource-Level Overrides

// Global handler override
export default defineAutoApiHandler({
  resource: 'users',

  // Disable multi-tenancy for this resource
  multiTenancy: {
    enabled: false,
  },

  operations: {
    list: true,
    // ...
  },
})

Audit Logging with Organization Context

// server/plugins/audit.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('autoApi:afterCreate', async (result, context) => {
    await db.insert(auditLogs).values({
      action: 'create',
      resource: context.resource,
      resourceId: result.id,
      userId: context.user?.id,
      organizationId: context.tenant?.id,  // ← Org context
      timestamp: new Date(),
    })
  })
})

Testing

Test Setup

// test/multi-tenancy.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { db } from '../server/database/db'
import { organizations, organizationMembers, users, posts } from '../server/database/schema'

describe('Multi-Tenancy', () => {
  let org1: any, org2: any
  let user1: any, user2: any
  let adminUser: any

  beforeEach(async () => {
    // Create organizations
    [org1, org2] = await db.insert(organizations).values([
      { id: 'org_a', name: 'Acme Corp', slug: 'acme' },
      { id: 'org_b', name: 'Globex Inc', slug: 'globex' },
    ]).returning()

    // Create users
    [user1, user2, adminUser] = await db.insert(users).values([
      { email: 'alice@acme.com', name: 'Alice' },
      { email: 'bob@globex.com', name: 'Bob' },
      { email: 'admin@example.com', name: 'Admin' },
    ]).returning()

    // Create memberships
    await db.insert(organizationMembers).values([
      { userId: user1.id, organizationId: org1.id, role: 'admin' },
      { userId: user2.id, organizationId: org2.id, role: 'member' },
      { userId: adminUser.id, organizationId: org1.id, role: 'owner' },
      { userId: adminUser.id, organizationId: org2.id, role: 'owner' },
    ])
  })

  it('lists posts scoped to user organization', async () => {
    // Create posts in different orgs
    await db.insert(posts).values([
      { title: 'Post 1', organizationId: org1.id, authorId: user1.id },
      { title: 'Post 2', organizationId: org2.id, authorId: user2.id },
    ])

    // User 1 (Org A) can only see Org A posts
    const response1 = await $fetch('/api/posts', {
      headers: { cookie: `session=${user1Session}` },
    })
    expect(response1.data).toHaveLength(1)
    expect(response1.data[0].title).toBe('Post 1')

    // User 2 (Org B) can only see Org B posts
    const response2 = await $fetch('/api/posts', {
      headers: { cookie: `session=${user2Session}` },
    })
    expect(response2.data).toHaveLength(1)
    expect(response2.data[0].title).toBe('Post 2')
  })

  it('auto-assigns organization on create', async () => {
    const newPost = await $fetch('/api/posts', {
      method: 'POST',
      body: { title: 'New Post', content: 'Test' },
      headers: { cookie: `session=${user1Session}` },
    })

    expect(newPost.organizationId).toBe(org1.id)
  })

  it('blocks cross-organization access', async () => {
    const org2Post = await db.insert(posts).values({
      title: 'Org B Post',
      organizationId: org2.id,
      authorId: user2.id,
    }).returning()

    // User 1 (Org A) cannot access Org B post
    await expect(
      $fetch(`/api/posts/${org2Post[0].id}`, {
        headers: { cookie: `session=${user1Session}` },
      })
    ).rejects.toThrow(/404|403/)
  })

  it('allows superadmin cross-tenant access', async () => {
    const adminSession = await createSession(adminUser, org1.id)

    // Admin can access all organizations' posts
    const response = await $fetch('/api/posts', {
      headers: { cookie: `session=${adminSession}` },
    })

    expect(response.data.length).toBeGreaterThanOrEqual(2)
  })
})

Testing Organization Switching

it('switches active organization', async () => {
  // User is member of both orgs
  await db.insert(organizationMembers).values([
    { userId: user1.id, organizationId: org1.id, role: 'admin' },
    { userId: user1.id, organizationId: org2.id, role: 'viewer' },
  ])

  // Create posts in each org
  await db.insert(posts).values([
    { title: 'Org A Post', organizationId: org1.id, authorId: user1.id },
    { title: 'Org B Post', organizationId: org2.id, authorId: user1.id },
  ])

  // User in Org A sees Org A posts
  let session = await createSession(user1, org1.id)
  let response = await $fetch('/api/posts', {
    headers: { cookie: `session=${session}` },
  })
  expect(response.data[0].title).toBe('Org A Post')

  // Switch to Org B
  await $fetch('/api/auth/organization/set-active', {
    method: 'POST',
    body: { organizationId: org2.id },
    headers: { cookie: `session=${session}` },
  })

  // Now sees Org B posts
  session = await refreshSession(session)
  response = await $fetch('/api/posts', {
    headers: { cookie: `session=${session}` },
  })
  expect(response.data[0].title).toBe('Org B Post')
})

Best Practices

1. Always Use Organization-Member Pattern

// ✅ GOOD: Roles on membership table
organizationMembers: {
  userId, organizationId, role: 'admin'
}

// ❌ BAD: Role directly on user
users: {
  id, email, role: 'admin'  // Role for ALL organizations
}

2. Validate Organization Membership

async objectLevel(post, context) {
  // Verify user is member of the resource's organization
  const isMember = await hasMinimumRole(
    context.user.id,
    post.organizationId,
    'viewer'
  )

  if (!isMember) return false

  // Then check specific permissions
  return post.authorId === context.user.id || isOrgAdmin(...)
}

3. Use Tenant Context, Not Query Params

// ✅ GOOD: Tenant from context
GET /api/posts
// Returns posts for context.tenant.id

// ❌ BAD: Tenant from query (can be spoofed)
GET /api/posts?organizationId=org_b
// User could access other orgs!

4. Cascade Delete Organization Data

organizationId: text('organization_id')
  .references(() => organizations.id, { onDelete: 'cascade' })

5. Test Cross-Tenant Scenarios

// Test matrix:
// - User in Org A cannot see Org B data
// - User in Org A cannot modify Org B data
// - Admin in Org A cannot access Org B
// - Superadmin can access all orgs
// - Organization switching works correctly

Troubleshooting

Multi-Tenancy Not Working

Problem: All organizations' data visible

Solution:

  1. Verify multiTenancy.enabled: true
  2. Check getTenantId returns correct org ID
  3. Ensure organizationId field exists in tables
  4. Check event.context.user?.organizationId is set

Cross-Tenant Data Leakage

Problem: User sees data from other organizations

Solution:

  1. Disable allowCrossTenantAccess for regular users
  2. Verify tenant context is set correctly
  3. Check excluded resources list
  4. Add integration tests for cross-tenant scenarios

API Tokens Not Scoped

Problem: API token accesses all organizations

Solution:

  1. Ensure orgField: 'organizationId' in token plugin config
  2. Verify tokens have organizationId column
  3. Check mapUser includes organizationId
  4. Test token introspection endpoint

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.