Multi-Tenancy
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
- Quick Start
- Schema Design
- Configuration
- Organization-Member Permissions
- Better-Auth Integration
- API Token Plugin
- Tenant Resolution Strategies
- Advanced Patterns
- 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 organizationMembers
✅ Different role per organization - role field on membership
✅ Resources scoped to organization - organizationId on posts
✅ Clean 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
| Property | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable multi-tenancy |
tenantIdField | string | 'organizationId' | Column name for tenant ID |
scopedResources | '*' | string[] | '*' | Which resources to scope |
excludedResources | string[] | [] | Global resources (not scoped) |
requireTenant | boolean | true | Require tenant for all operations |
getTenantId | function | - | Extract tenant ID from request |
allowCrossTenantAccess | function | - | 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:
- Extracts organization from token record
- Sets tenant context automatically:
context.tenant = { id: token.organizationId, field: 'organizationId', canAccessAllTenants: false, // Tokens are single-org only } - All operations scoped to token's organization
- 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
1. From User Context (Recommended)
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:
- Verify
multiTenancy.enabled: true - Check
getTenantIdreturns correct org ID - Ensure
organizationIdfield exists in tables - Check
event.context.user?.organizationIdis set
Cross-Tenant Data Leakage
Problem: User sees data from other organizations
Solution:
- Disable
allowCrossTenantAccessfor regular users - Verify tenant context is set correctly
- Check excluded resources list
- Add integration tests for cross-tenant scenarios
API Tokens Not Scoped
Problem: API token accesses all organizations
Solution:
- Ensure
orgField: 'organizationId'in token plugin config - Verify tokens have
organizationIdcolumn - Check
mapUserincludesorganizationId - Test token introspection endpoint
See Also
- Authorization Guide - Permission system
- Better-Auth Integration - Session management
- API Token Plugin - Token authentication
- Permissions - Resource permissions
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.
Validation
@websideproject/nuxt-auto-api uses Zod for validation with automatic schema generation from Drizzle tables via drizzle-zod.