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
| Aspect | Control Plane | Child Tenants |
|---|---|---|
| Purpose | Manages all tenants | Isolated customer environments |
| Organizations | Map to child tenants | Internal business units |
| Users on Orgs | Tenant administrators | Not used for tenant access |
| Resource Servers | Synced to all tenants | Synced from control plane |
| Roles | Synced to all tenants | Synced from control plane |
| End Users | System administrators | Customer 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.
// 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: trueKey Points:
- Marked as
is_system: trueon 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.
// 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: trueKey Points:
- Marked as
is_system: trueon 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.
// 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 tenantsConfiguration
Enable entity synchronization when setting up multi-tenancy:
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:
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:
// 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:
// 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 tenantOrganizations on Child Tenants
Organizations on child tenants represent internal business units within that tenant:
// 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:
// 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 departmentAPI Access Methods
There are three ways to call tenant-scoped APIs:
1. Organization Token (Recommended)
Request a token with an organization claim via silent authentication:
// 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 tenantHow it works:
- Token includes
org_name: "acme"(ifallow_organization_name_in_authentication_apiis 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:
// 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:
// 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
| Method | Token Type | Tenant Selection | Use Case |
|---|---|---|---|
| Organization Token | Control plane with org claim | Via org_name/organization_id | Production apps, standard OAuth flow |
| Token + Header | Control plane | Via X-Tenant-ID header | Admin tools, backend services |
| Tenant Token | Tenant-specific | Via subdomain | White-label, isolated deployments |
Access Control Flow
Accessing Control Plane
// 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
// 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 ForbiddenExample: Complete Multi-Tenant Setup
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),
});Best Practices
1. Use org_name for Tenant Access
Enable allow_organization_name_in_authentication_api on your applications:
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:
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:
// ✅ 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:
// ✅ 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
- Tenant Lifecycle - Learn about creating and managing tenants
- Database Isolation - Set up per-tenant databases
- Runtime Fallback - Inherit configuration from control plane at runtime
- API Reference - Complete API documentation