Skip to main content
Vertz supports multi-tenant applications where a single user belongs to multiple tenants (organizations, workspaces, teams). The tenant config in createAuth() enables:
  • Tenant-scoped JWTs — each session is scoped to one tenant at a time
  • Tenant switching — users switch between tenants without re-authenticating
  • Tenant listing — fetch all tenants a user belongs to
  • Auto-resolve — automatically select the right tenant on login (last-accessed or custom logic)
  • Last-accessed tracking — the server remembers which tenant the user last switched to

Quick start

import { createAuth, createServer } from 'vertz/server';

const auth = createAuth({
  session: { strategy: 'jwt', ttl: '60s' },
  tenant: {
    // Required — verify the user belongs to the target tenant
    verifyMembership: async (userId, tenantId) => {
      const member = await db.query(
        'SELECT 1 FROM tenant_members WHERE user_id = ? AND tenant_id = ?',
        [userId, tenantId],
      );
      return member.length > 0;
    },

    // Optional — list all tenants for the user
    listTenants: async (userId) => {
      return db.query(
        'SELECT t.id, t.name, t.logo FROM tenants t JOIN tenant_members tm ON t.id = tm.tenant_id WHERE tm.user_id = ?',
        [userId],
      );
    },

    // Optional — custom logic to pick the default tenant
    resolveDefault: async (userId, tenants) => {
      // Example: prefer the tenant marked as primary
      const primary = tenants.find((t) => t.isPrimary);
      return primary?.id;
    },
  },
});

const app = createServer({
  entities: [
    /* ... */
  ],
  db,
  auth,
});
This generates:
GET  /api/auth/tenants        → List tenants + resolve default
POST /api/auth/switch-tenant  → Switch tenant (re-issues JWT)

Configuration

tenant.verifyMembership (required)

Called before every tenant switch. Return false to deny — the endpoint returns 403.
verifyMembership: async (userId: string, tenantId: string) => boolean;

tenant.listTenants (optional)

Enables the GET /api/auth/tenants endpoint. Without this, the endpoint returns 404.
listTenants: async (userId: string) => TenantInfo[]
TenantInfo requires id and name. You can add any extra fields (logo, plan, role) — they pass through to the client:
interface TenantInfo {
  id: string;
  name: string;
  [key: string]: unknown; // logo, plan, memberCount, etc.
}

tenant.resolveDefault (optional)

Determines which tenant to auto-select when a session has no tenantId (e.g., right after login). Called during GET /api/auth/tenants.
resolveDefault: async (userId: string, tenants: TenantInfo[]) => string | undefined;
Default behavior (when resolveDefault is not provided):
  1. Use lastTenantId (the last tenant the user switched to)
  2. Fall back to the first tenant in the list
  3. Return undefined if the user has no tenants

How it works

Tenant-scoped JWTs

When a user switches tenants, the server issues a new JWT with tenantId in the payload. All subsequent requests carry this scoped JWT. Entity access rules can use rules.where({ tenantId: rules.user.tenantId }) to filter data by the current tenant. The tenantId is preserved across token refreshes — switching tenant once persists until the user explicitly switches again.

Last-accessed tracking

Every successful tenant switch updates the user’s lastTenantId in the user store. This is used by the default resolveDefault strategy and is returned in the GET /api/auth/tenants response.

Endpoints

GET /api/auth/tenants

Returns the user’s tenants, current session tenant, last-accessed tenant, and the resolved default. Response:
{
  "tenants": [
    { "id": "org-1", "name": "Acme Corp", "logo": "/logos/acme.png" },
    { "id": "org-2", "name": "Startup Inc" }
  ],
  "currentTenantId": "org-1",
  "lastTenantId": "org-1",
  "resolvedDefaultId": "org-1"
}
FieldTypeDescription
tenantsTenantInfo[]All tenants the user belongs to
currentTenantIdstring | undefinedTenant in the current JWT (if any)
lastTenantIdstring | undefinedLast tenant the user switched to
resolvedDefaultIdstring | undefinedRecommended tenant to auto-select
Errors:
StatusWhen
401No valid session
404listTenants not configured

POST /api/auth/switch-tenant

Switches the session to a different tenant. Issues a new JWT scoped to the target tenant. Request:
{ "tenantId": "org-2" }
Response:
{
  "tenantId": "org-2",
  "user": { "id": "user-1", "email": "alice@example.com" },
  "expiresAt": 1711036800000
}
New session cookies (vertz.sid, vertz.ref) are set automatically. Errors:
StatusWhen
401No valid session
403User is not a member of the target tenant
404Tenant config not enabled

Entity tenant scoping

Entities with a tenantId field are automatically scoped by the framework. The access system adds rules.where({ tenantId: rules.user.tenantId }) to all operations, so queries only return rows belonging to the current tenant.
const projects = entity('projects', {
  model: projectsModel,
  access: {
    list: rules.authenticated(),
    // tenantId filtering is automatic — no need to add it manually
  },
});
To opt out for cross-tenant entities:
entity('system-template', {
  tenantScoped: false,
  // ...
});

Multi-level tenancy

Real-world SaaS apps often have multiple levels of tenancy — an account contains projects, or an agency manages organizations that contain brands. Vertz supports this natively: multiple .tenant() tables form a hierarchy, with billing, access, and data isolation at different levels.

Define the hierarchy

Mark multiple tables as .tenant(). The framework infers the hierarchy from FK relationships:
import { d } from '@vertz/db';

const accounts = d.table('accounts', {
  id: d.id(),
  name: d.text(),
}).tenant();

const projects = d.table('projects', {
  id: d.id(),
  name: d.text(),
  accountId: d.text(), // FK → accounts.id
}).tenant();
The framework detects that projects.accountId references accounts.id and infers: account → project (root → leaf). Up to 4 levels are supported.

Per-level billing

Plans get a level field that targets a specific entity in the hierarchy:
const access = defineAccess({
  entities: {
    account: { roles: ['owner', 'admin'] },
    project: {
      roles: ['admin', 'editor'],
      inherits: { 'account:owner': 'admin', 'account:admin': 'admin' },
    },
  },
  entitlements: {
    'project:ai-generate': { roles: ['admin', 'editor'] },
  },
  plans: {
    enterprise: {
      group: 'account-plans',
      level: 'account',                // Assigned to accounts
      features: ['project:ai-generate'],
      limits: {
        'ai-credits': { max: 10_000, gates: 'project:ai-generate', per: 'month' },
      },
    },
    pro: {
      group: 'project-plans',
      level: 'project',                // Assigned to projects
      features: ['project:ai-generate'],
      limits: {
        'ai-credits': { max: 500, gates: 'project:ai-generate', per: 'month' },
      },
    },
  },
  defaultPlans: {
    account: 'enterprise',
    project: 'pro',
  },
});
Plans at different levels use different group names (mutual exclusivity is per-level). Use defaultPlans (keyed by entity name) instead of defaultPlan for multi-level.

Feature resolution modes

When the same entitlement is gated at multiple levels, the framework supports two resolution modes:
ModeBehaviorUse case
inherit (default)Parent plan grants features to childrenEnterprise account unlocks features for all projects
localOnly the deepest level’s plan is checkedProject must have its own plan to use a feature
entitlements: {
  'project:ai-generate': {
    roles: ['admin', 'editor'],
    featureResolution: 'inherit',  // default — account plan cascades down
  },
  'project:custom-domain': {
    roles: ['admin'],
    featureResolution: 'local',    // project must have its own plan
  },
},

Cascaded wallet consumption

When consuming a limit at a child level, the wallet is incremented at every ancestor level that has a plan with the same limit. This enforces ceilings at all levels:
Account: enterprise (10,000 ai-credits/month)
  └── Project A: pro (500 ai-credits/month)
  └── Project B: pro (500 ai-credits/month)
When Project A consumes 1 credit:
  • Project A wallet: +1 (of 500)
  • Account wallet: +1 (of 10,000)
If the account reaches 10,000 total across all projects, further consumption is denied even if individual projects still have room in their 500 limit. unconsume() mirrors this — decrementing all ancestor levels.

Level-aware tenant filtering

Entities scoped to a non-leaf level are filtered correctly. If billing_invoices is scoped to the account level and the user is at the project level, the framework walks the ancestor chain to find the account ID:
// User scoped to project_abc (child of account_xyz)
// Listing invoices → filters by accountId = account_xyz

Flag resolution

When the same flag exists at multiple levels, deepest wins (most specific overrides parent):
Account flag: beta_ai = true
Project flag: beta_ai = false
→ Resolved at project level: false

Backward compatibility

Single .tenant() apps work unchanged — no level, no defaultPlans, no ancestorResolver. The multi-level features are entirely opt-in.

Session payload

Multi-level sessions include tenantLevel alongside tenantId:
// JWT payload includes:
{
  tenantId: 'proj-123',
  tenantLevel: 'project',  // Which level the tenantId belongs to
}
The switch-tenant endpoint resolves tenantLevel from the closure table automatically.

Next steps

Client-Side Tenants

TenantProvider, useTenant(), and TenantSwitcher component.

Authentication

JWT sessions, RBAC, plans, and full auth configuration.

Access Control

Use entitlements and access rules in your UI.