Skip to content

Control Plane Architecture

The multi-tenancy package uses a control plane architecture where a central tenant manages and provisions all other tenants in the system.

Overview

The control plane acts as the management layer for your entire multi-tenant system:

  • Centralized Management: All tenant management operations happen on the control plane
  • Entity Synchronization: Resource servers and roles from the control plane are synced to child tenants
  • Organization Mapping: Organizations on the control plane map to individual child tenants
  • Access Control: Controls who can access which tenants via organization membership

Control Plane vs Child Tenants

┌───────────────────────────────────────────────────────────────────────┐
│                        CONTROL PLANE (main)                           │
│                                                                       │
│  Organizations                    System Entities                     │
│  ┌───────────────┐               ┌──────────────────┐                 │
│  │ org: "acme"   │               │ Resource Servers │                 │
│  │ users:        │               │  - Management API│                 │
│  │  - alice      │               │  - My API        │                 │
│  │  - bob        │               │                  │                 │
│  └───────────────┘               │ Roles            │                 │
│                                  │  - Admin         │                 │
│  ┌───────────────┐               │  - User          │                 │
│  │ org: "widgets"│               │  - Viewer        │                 │
│  │ users:        │               └──────────────────┘                 │
│  │  - charlie    │                                                    │
│  └───────────────┘                                                    │
│                                                                       │
└─────────────────┬─────────────────────────────────┬───────────────────┘
                  │ Synced Entities                 │
                  ▼                                 ▼
       ┌──────────────────┐              ┌──────────────────┐
       │ TENANT: acme     │              │ TENANT: widgets  │
       │                  │              │                  │
       │ Organizations    │              │ Organizations    │
       │  - Sales Dept    │              │  - Engineering   │
       │  - Marketing     │              │  - Product       │
       │                  │              │                  │
       │ Resource Servers │              │ Resource Servers │
       │  - Management API│ (synced)     │  - Management API│ (synced)
       │  - My API        │ (synced)     │  - My API        │ (synced)
       │                  │              │                  │
       │ Roles            │              │ Roles            │
       │  - Admin         │ (synced)     │  - Admin         │ (synced)
       │  - User          │ (synced)     │  - User          │ (synced)
       │  - Viewer        │ (synced)     │  - Viewer        │ (synced)
       │                  │              │                  │
       │ Users            │              │ Users            │
       │  - end-user-1    │              │  - end-user-2    │
       │  - end-user-2    │              │  - end-user-3    │
       └──────────────────┘              └──────────────────┘

Key Differences

AspectControl PlaneChild Tenants
PurposeManages all tenantsIsolated customer environments
OrganizationsMap to child tenantsInternal business units
Users on OrgsTenant administratorsNot used for tenant access
Resource ServersSynced to all tenantsSynced from control plane
RolesSynced to all tenantsSynced from control plane
End UsersSystem administratorsCustomer end users

Entity Synchronization

When you create or update entities on the control plane, they are automatically synchronized to all child tenants.

Synced Entities

1. Resource Servers

Resource servers created on the control plane are automatically synced to all child tenants with the is_system: true flag.

typescript
// Create a resource server on control plane
await adapters.resourceServers.create("main", {
  name: "My API",
  identifier: "https://api.example.com",
  scopes: [
    { value: "read:data", description: "Read data" },
    { value: "write:data", description: "Write data" },
  ],
});

// Automatically synced to all child tenants:
// - tenant: acme
// - tenant: widgets
// - tenant: demo
// All with is_system: true

Key Points:

  • Marked as is_system: true on child tenants
  • Cannot be modified on child tenants
  • Updates on control plane are synced to all tenants
  • Deletions on control plane remove from all tenants

2. Roles

Roles created on the control plane are automatically synced to all child tenants.

typescript
// Create a role on control plane
await adapters.roles.create("main", {
  name: "Admin",
  description: "Administrator role",
});

// Automatically synced to all child tenants with is_system: true

Key Points:

  • Marked as is_system: true on child tenants
  • Cannot be modified on child tenants
  • Role permissions are also synced
  • Updates and deletions are propagated

3. Role Permissions

When roles are synced, their permissions are also synchronized.

typescript
// Assign permissions on control plane
await adapters.rolePermissions.assign("main", adminRoleId, [
  {
    role_id: adminRoleId,
    resource_server_identifier: "https://api.example.com",
    permission_name: "read:data",
  },
]);

// Permissions are synced to the same role on all child tenants

Opting Out of Sync

To keep a resource server or role on the control plane only — without propagating it to child tenants — set metadata.sync to false:

typescript
// Control-plane-only API; never synced to child tenants
await adapters.resourceServers.create("main", {
  name: "Internal Ops API",
  identifier: "https://ops.internal.example.com",
  metadata: { sync: false },
});

The default sync filter checks metadata.sync !== false before mirroring to child tenants, and it applies in both directions:

  • New or updated entities on the control plane are not pushed out.
  • Newly created child tenants do not receive the entity at provisioning time.

The same flag works for roles. For more elaborate rules, supply custom filters when constructing the hooks; they compose with the metadata.sync check rather than replacing it (an entity must pass both to be synced):

typescript
const { entityHooks, tenantHooks } = createSyncHooks({
  controlPlaneTenantId: "main",
  getChildTenantIds,
  getAdapters,
  getControlPlaneAdapters,
  filters: {
    resourceServers: (rs) => !rs.identifier.startsWith("https://internal."),
  },
});

Configuration

Enable entity synchronization when setting up multi-tenancy:

typescript
import {
  setupMultiTenancy,
  createTenantResourceServerSyncHooks,
  createTenantRoleSyncHooks,
} from "@authhero/multi-tenancy";

// Create sync hooks
const resourceServerSync = createTenantResourceServerSyncHooks({
  controlPlaneTenantId: "main",
  getControlPlaneAdapters: async () => mainAdapters,
  getAdapters: async (tenantId) => getTenantAdapters(tenantId),
});

const roleSync = createTenantRoleSyncHooks({
  controlPlaneTenantId: "main",
  getControlPlaneAdapters: async () => mainAdapters,
  getAdapters: async (tenantId) => getTenantAdapters(tenantId),
  syncPermissions: true, // Also sync role permissions
});

// Setup multi-tenancy with sync hooks
const multiTenancy = setupMultiTenancy({
  accessControl: {
    controlPlaneTenantId: "main",
  },
  hooks: {
    resourceServers: resourceServerSync,
    roles: roleSync,
  },
});

Protected Entities Middleware

System entities synced from the control plane are protected from modification on child tenants:

typescript
import { createProtectSyncedMiddleware } from "@authhero/multi-tenancy";

// Apply middleware to management API
app.use("/api/v2/*", createProtectSyncedMiddleware());

// Now attempts to modify synced entities will return 403
// PATCH /api/v2/resource-servers/:id (where is_system: true)
// Response: 403 "This resource server is a system resource and cannot be modified"

Organizations: Control Plane vs Child Tenants

Organizations serve different purposes depending on where they exist:

Organizations on Control Plane

Organizations on the control plane represent child tenants:

typescript
// Create a new tenant
await fetch("/management/tenants", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    id: "acme",
    friendly_name: "Acme Corporation",
  }),
});

// This automatically creates:
// 1. Tenant with id "acme"
// 2. Organization on control plane with name "acme"

Key characteristics:

  • Organization name = tenant ID
  • Membership controls tenant administrator access
  • Used for access control to tenant management APIs

Example use case:

typescript
// Alice is added to the "acme" organization on control plane
// This grants her access to manage the acme tenant via:
// - Token with org_name: "acme" or organization_id: "acme"
// - Can call management APIs for acme tenant

Organizations on Child Tenants

Organizations on child tenants represent internal business units within that tenant:

typescript
// On the "acme" tenant, create departments
await adapters.organizations.create("acme", {
  name: "sales-dept",
  display_name: "Sales Department",
});

await adapters.organizations.create("acme", {
  name: "engineering",
  display_name: "Engineering Department",
});

Key characteristics:

  • Represent departments, teams, or business units
  • Used for B2B customer organization management
  • Not used for tenant access control
  • End users belong to these organizations

Example use case:

typescript
// Acme Corporation has two departments:
// 1. Sales Department - has access to CRM features
// 2. Engineering - has access to technical resources
// End users get tokens with org_id for their department

API Access Methods

There are three ways to call tenant-scoped APIs:

Request a token with an organization claim via silent authentication:

typescript
// Get token for "acme" tenant
const token = await auth.getTokenSilently({
  authorizationParams: {
    organization: "acme",
  },
});

// Call any API for acme tenant
const response = await fetch("https://api.example.com/api/v2/users", {
  headers: {
    Authorization: `Bearer ${token}`,
  },
});

// Token contains org_name: "acme" or organization_id: "org_xxx"
// Middleware automatically routes to acme tenant

How it works:

  • Token includes org_name: "acme" (if allow_organization_name_in_authentication_api is enabled)
  • Or organization_id: "org_xxx" where org.name = "acme"
  • Access control middleware validates organization membership on control plane
  • Request is automatically scoped to the acme tenant

Best for:

  • Production applications
  • Frontend/mobile apps
  • Standard OAuth2/OIDC flows

2. Control Plane Token + Tenant Header

Use a control plane token with an explicit tenant ID header:

typescript
// Get control plane token (no organization)
const token = await auth.getTokenSilently();

// Call API with tenant header
const response = await fetch("https://api.example.com/api/v2/users", {
  headers: {
    Authorization: `Bearer ${token}`,
    "X-Tenant-ID": "acme", // or "tenant-id": "acme"
  },
});

How it works:

  • Token is for control plane (no org_id)
  • Tenant header explicitly specifies target tenant
  • Access control validates user has access to specified tenant
  • Request is scoped to the tenant from header

Best for:

  • Administrative scripts
  • Backend services
  • Migration tools
  • Testing scenarios

3. Tenant-Specific Token

Request a token directly from a tenant's authorization endpoint:

typescript
// Login directly to acme tenant
const token = await fetch("https://acme.auth.example.com/oauth/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "password",
    username: "user@acme.com",
    password: "password",
    client_id: "client_id",
    scope: "openid profile",
  }),
});

// Use token for acme tenant
const response = await fetch("https://acme.auth.example.com/api/v2/users", {
  headers: {
    Authorization: `Bearer ${token.access_token}`,
  },
});

How it works:

  • Subdomain routing determines tenant (acme.auth.example.com → acme)
  • Token is issued specifically for acme tenant
  • No organization claim needed
  • Request is automatically scoped via subdomain

Best for:

  • Subdomain-based deployments
  • Tenant-specific domains
  • White-label scenarios
  • Isolated tenant access

Comparison Table

MethodToken TypeTenant SelectionUse Case
Organization TokenControl plane with org claimVia org_name/organization_idProduction apps, standard OAuth flow
Token + HeaderControl planeVia X-Tenant-ID headerAdmin tools, backend services
Tenant TokenTenant-specificVia subdomainWhite-label, isolated deployments

Access Control Flow

Accessing Control Plane

typescript
// User alice has no organization claim - token for control plane access
const token = {
  sub: "alice",
  // No org_id or org_name
};

// ✅ Can access control plane management APIs
const response = await fetch("/management/tenants", {
  headers: {
    Authorization: `Bearer ${accessToken}`,
  },
});

// ✅ Can list all tenants alice has access to
// (based on organization memberships on control plane)

Accessing Child Tenant

typescript
// User alice is member of "acme" organization on control plane
const token = {
  sub: "alice",
  org_name: "acme", // or organization_id: "org_xxx"
};

// ✅ Can access acme tenant
const response = await fetch("/api/v2/users", {
  headers: {
    Authorization: `Bearer ${accessToken}`,
  },
});

// ❌ Cannot access widgets tenant (not a member of that organization)
const forbidden = await fetch("/api/v2/users", {
  headers: {
    Authorization: `Bearer ${accessToken}`,
    "X-Tenant-ID": "widgets",
  },
});
// Response: 403 Forbidden

Example: Complete Multi-Tenant Setup

typescript
import { init } from "@authhero/authhero";
import { getAdapters } from "./adapters";

const app = await init({
  multiTenancy: {
    // Define control plane
    accessControl: {
      controlPlaneTenantId: "main",
      requireOrganizationMatch: true,
      defaultPermissions: ["tenant:admin"],
    },

    // Enable subdomain routing
    subdomainRouting: {
      baseDomain: "auth.example.com",
      reservedSubdomains: ["www", "api", "admin"],
    },

    // Sync entities from control plane
    entitySync: {
      resourceServers: true,
      roles: true,
      permissions: true,
    },

    // Database isolation per tenant
    databaseIsolation: {
      createDatabase: async (tenantId) => {
        // Create D1 database or Turso instance
        const db = await createTenantDatabase(tenantId);
        return getAdapters(db);
      },
      deleteDatabase: async (tenantId) => {
        await deleteTenantDatabase(tenantId);
      },
    },
  },

  // Your other config
  issuer: "https://auth.example.com/",
  getAdapters: () => getAdapters(mainDb),
});

The /connect/start endpoint mints an RFC 7591 Initial Access Token bound to user consent — a third-party site (e.g. a WordPress publisher) sends the user's browser to AuthHero, the user confirms, and the resulting IAT can be exchanged for a registered client at POST /oidc/register.

When the request resolves to a control plane tenant, the flow gains an extra workspace-picker step so the IAT (and the client it produces) lands on the right child tenant. When the request resolves to a child tenant directly, the picker is skipped.

Detecting the mode

The connect screen branches on data.multiTenancyConfig.controlPlaneTenantId, which withRuntimeFallback (and therefore initMultiTenant) sets on the data adapter automatically. No extra wiring is required — if you initialised AuthHero with the multi-tenancy plugin, control-plane mode is already on.

How the picker works

Browser → GET /connect/start?…           ← request resolves to control plane
AuthHero → 302 /u2/connect/start?state=<sid>
        → no session: 302 /u2/login/identifier?state=<sid>
        → after login: 302 /u2/connect/select-tenant?state=<sid>
User    → picks workspace (one button per accessible org)
        → state_data.connect.target_tenant_id is persisted
        → 302 /u2/connect/start?state=<sid>
        → consent screen renders, showing the chosen workspace
User    → confirms
AuthHero → mint IAT on the *child* tenant
         → 302 return_to?authhero_iat=<token>
                       &authhero_tenant=<child_tenant_id>
                       &state=<csrf>

The picker enumerates the user's organizations on the control plane via userOrganizations.listUserOrganizations. Each organization name maps 1:1 to a child tenant id (the convention enforced by the provisioning hooksorg.name === tenant.id), and any orgs that don't resolve to an existing tenant are filtered out.

Membership is re-validated when consent is submitted, so a stale or tampered target_tenant_id cannot mint on a workspace the user has lost access to between picker and consent.

Direct-to-child mode

If the request resolves to a child tenant — for example via a custom domain like acme.auth.example.com that maps to the acme tenant — the picker step is bypassed entirely. The flow is identical to the single-tenant case: login → consent → IAT minted on the resolved tenant. No authhero_tenant parameter is added to the redirect because the integrator already knows the tenant from the URL it pointed at.

The authhero_tenant callback parameter

When the IAT is minted on a tenant different from the request's resolved tenant (i.e. always in control-plane mode, never in direct-to-child mode), the success redirect appends authhero_tenant=<child_tenant_id> alongside authhero_iat. The integrator must use this value as the tenant-id header on POST /oidc/register so the registration call is routed to the correct tenant.

js
// Example: a CMS handling the connect callback
const url = new URL(window.location.href);
const iat = url.searchParams.get("authhero_iat");
const tenant = url.searchParams.get("authhero_tenant"); // present only in control-plane mode

await fetch("https://auth2.example.com/oidc/register", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${iat}`,
    "tenant-id": tenant ?? "", // omit entirely if not present (direct-to-child)
    "content-type": "application/json",
  },
  body: JSON.stringify({
    client_name: "My WordPress Site",
    redirect_uris: ["https://publisher.com/wp-admin/callback"],
    grant_types: ["client_credentials"],
  }),
});

The IAT itself enforces all the constraints captured at consent time (domain, integration_type, grant_types, optional scope) — those are not affected by which tenant minting happens on.

Choosing your entry point

Pick the mode that matches the integrator's view of your system:

Entry pointModeWhen to use
Control plane host (auth2.sesamy.com)Control planeA single canonical URL across all integrators. Users without a per-tenant subdomain. The picker is the natural place to disambiguate which workspace owns the connection.
Child tenant host (acme.auth…)Direct-to-childThe integrator already knows which tenant they're connecting to (e.g. white-label deployments, self-service sign-up that bakes the tenant into the install).

Both modes can coexist on the same AuthHero deployment — there is no global setting to flip.

Limitations

  • The picker assumes a single shared database or per-tenant databases reachable from the same data adapter. With strict database isolation, control-plane minting needs the runtime to swap adapters before calling mintIat against the chosen child — that wiring is not yet exposed.
  • Users with zero matching organizations see a "no workspaces available" message instead of the picker; they cannot complete the connect flow until invited.

Best Practices

1. Use org_name for Tenant Access

Enable allow_organization_name_in_authentication_api on your applications:

typescript
await adapters.clients.update("main", clientId, {
  allow_organization_name_in_authentication_api: true,
});

This ensures tokens contain org_name which directly maps to tenant IDs, avoiding the need to lookup organization IDs.

2. Protect System Entities

Always use the protect synced middleware:

typescript
import { createProtectSyncedMiddleware } from "@authhero/multi-tenancy";

app.use("/api/v2/*", createProtectSyncedMiddleware());

3. Centralize Entity Management

Create all shared resource servers and roles on the control plane:

typescript
// ✅ Create on control plane - syncs to all tenants
await createResourceServer("main", config);

// ❌ Don't create individually on each tenant
// await createResourceServer("acme", config);
// await createResourceServer("widgets", config);

4. Separate Admin and End Users

  • Control plane users: Tenant administrators, manage via organizations
  • Child tenant users: End customers, authenticate to their specific tenant

5. Use Tenant Headers for Admin Operations

For administrative scripts and backend services, use the control plane token with tenant headers rather than switching organizations:

typescript
// ✅ Simple admin script
const adminToken = await getControlPlaneToken();

for (const tenant of tenants) {
  await fetch(`/api/v2/users`, {
    headers: {
      Authorization: `Bearer ${adminToken}`,
      "X-Tenant-ID": tenant.id,
    },
  });
}

Next Steps

Released under the MIT License.