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 tenantsOpting 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:
// 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):
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:
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: "[email protected]",
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),
});Consent-mediated DCR (/connect/start)
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 hooks — org.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.
// 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 point | Mode | When to use |
|---|---|---|
Control plane host (auth2.sesamy.com) | Control plane | A 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-child | The 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
mintIatagainst 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.
Custom domains: the control plane is authoritative
A Cloudflare-for-SaaS custom hostname is an account-global resource in one shared zone. Two things follow, and both of them are impossible on a tenant shard:
- Registering the hostname needs Cloudflare account credentials (
zoneId/authKey/authEmail). By design those live only on the control plane. - Claiming the hostname exactly once needs a view across every tenant. Two WFP tenants can't both own
login.acme.com, but neither tenant's database can see the other's rows.
So the control plane owns the row and the tenant's database holds a read-cache mirror. A tenant shard writes through the control plane synchronously rather than writing locally and replicating upward.
tenant: POST /api/v2/custom-domains
→ createControlPlaneCustomDomainsAdapter
→ POST {control plane}/api/v2/proxy/control-plane/custom-domains
1. hostname owned by another tenant? → 409, nothing written anywhere
2. register the hostname in Cloudflare (account credentials live here)
3. persist the authoritative row
→ 2xx: mirror the row into the tenant's own database
→ 409: surface the conflict; write nothing locallyNothing is written locally on a conflict, which is what prevents the half-provisioned, unroutable row this design replaces.
Tenant shard
Wrap the shard's own customDomains adapter as the mirror:
import {
createControlPlaneClient,
createControlPlaneCustomDomainsAdapter,
createServiceBindingFetch,
createServiceTokenCore,
} from "authhero";
const client = createControlPlaneClient({
baseUrl: env.CONTROL_PLANE_URL,
// On Workers for Platforms, route the call over a service binding to the
// control-plane Worker. A tenant Worker in the dispatch namespace calling the
// public edge would leave Cloudflare and re-enter through the proxy that
// dispatched it. Omit it and the call falls back to the public edge.
fetchImpl: createServiceBindingFetch(env.CONTROL_PLANE),
// Signed by this shard's own key; the control plane verifies it against the
// shard's published JWKS. No shared client secret.
getServiceToken: async (tenantId, scope) => {
const token = await createServiceTokenCore({
tenants: adapters.tenants,
keys: adapters.keys,
tenantId,
scope,
issuer,
});
return token.access_token;
},
});
const dataAdapter = {
...adapters,
customDomains: createControlPlaneCustomDomainsAdapter({
client,
mirror: adapters.customDomains,
}),
};Read freshness. get/list trust the mirror once a domain is ready. A fresh domain is pending until the customer adds the DV record, and that transition happens at the control plane — so a non-ready row is refreshed upstream on read. getByDomain always reads the mirror: it is on the tenant-resolution path for every request to a custom domain and must never take a network hop.
Control plane
Mount the authoritative resource by passing the Cloudflare adapter (wrapping the control-plane database) as proxyControlPlane.customDomains:
import createCloudflareAdapters from "@authhero/cloudflare-adapter";
export default init({
dataAdapter,
proxyControlPlane: {
resolveHost: createProxyDataAdapter(db).resolveHost,
customDomains: createCloudflareAdapters({
zoneId: env.CLOUDFLARE_ZONE_ID,
authKey: env.CLOUDFLARE_API_KEY,
authEmail: env.CLOUDFLARE_API_EMAIL,
customDomainAdapter: dataAdapter.customDomains,
}).customDomains,
},
});Tokens for this resource must carry the controlplane:custom_domains scope; the resource is not mounted when no adapter is configured.
Authorization is bound to the token's tenant. Every shard holds this scope, so the scope says who is calling, not what they may touch. Each operation acts on the tenant_id claim of the verified token: a request naming a different tenant is refused with 403, and a token with no tenant claim is refused outright. The same rule applies to POST /sync — a shard may only replicate its own rows.
Without CONTROL_PLANE_URL, a tenant shard refuses custom-domain writes (501) rather than writing a local row that Cloudflare never hears about. Reads keep working, so any domain already mirrored there still resolves.
Tenant team: self-service member management
A tenant's team — the people who administer it — is not the tenant's users. It is an organization on the control-plane tenant whose name equals the tenant id, its members are control-plane users, and their access is an org membership plus an org-scoped role. A tenant shard cannot write those rows. Putting administrators in the tenant's own users table would be a category error, and letting the org-scoped token call the control-plane organizations/{id}/members endpoints directly is unsafe — those routes are permission-gated but not scoped to the caller's own organization, so a tenant admin could edit other tenants' teams by changing the id in the URL.
This is the same shape as custom domains — an authoritative side effect the shard can't perform — but with no read-cache mirror: nothing in the tenant's request path ever reads "who administers me" (authorization already happens against the org_name/org_id claim the control plane put in the token). So it is a pure pass-through delegation, not write-through-plus-cache.
tenant admin → GET/POST/DELETE /api/v2/tenant-members (on the shard)
→ pin: token org_name must equal the request tenant ── else 403
→ createControlPlaneTenantMembersAdapter
→ …/api/v2/proxy/control-plane/tenant-members (on the control plane)
→ pin again: act on the verified token's tenant_id ── else 403
→ resolve org (name == tenant_id), then read/write
membership · org-scoped roles · invitationsTwo independent org pins. The shard's /api/v2/tenant-members resource requires the token's org_name claim to equal the request tenant, so a tenant-A admin cannot manage tenant B by swapping the tenant-id header. The control-plane resource then re-pins to the verified service token's tenant_id claim. A request that names a different tenant is refused with 403 at both hops. Tokens for the control-plane resource carry the controlplane:tenant_members scope.
Tenant shard
Enable the resource with tenantMembers.getBackend, returning a control-plane-backed adapter that reuses the same client as custom domains:
import { createControlPlaneTenantMembersAdapter } from "authhero";
export default init({
dataAdapter,
// `client` is the createControlPlaneClient from the custom-domains wiring above.
tenantMembers: {
getBackend: () => createControlPlaneTenantMembersAdapter({ client }),
},
});Control plane
Mount the authoritative resource with proxyControlPlane.tenantMembers. Its backend resolves the org and mutates the control-plane database directly:
import { createLocalTenantMembersBackend } from "authhero";
export default init({
dataAdapter,
proxyControlPlane: {
resolveHost: createProxyDataAdapter(db).resolveHost,
tenantMembers: {
getBackend: (c) =>
createLocalTenantMembersBackend({
data: c.env.data, // control-plane adapters
controlPlaneTenantId: CONTROL_PLANE_TENANT_ID,
issuer, // builds invitation acceptance links + default avatars
invitationClientId: env.INVITATION_CLIENT_ID,
// Optional: any async sender. Omit it and the invitation is still
// created and returned; only email delivery is skipped (like Auth0).
sendInvitationEmail: async ({ to, invitationUrl }) =>
deliverEmail(c, to, invitationUrl),
}),
},
},
});Single-instance deployments
When the control plane and the tenants share one database, there is no hop: wire the local backend straight into tenantMembers.getBackend (the same createLocalTenantMembersBackend) and omit proxyControlPlane.tenantMembers. The shard's /api/v2/tenant-members resource then resolves the org against the same database it already holds.
Admin UI
The per-tenant admin ships a Team page (Settings → Team) that drives this resource: invite colleagues by email, remove administrators, and edit each administrator's roles. The invitation client is resolved server-side, so — unlike the control-plane-only /tenants/:id/members page — it does not depend on a client id being present in local storage. Because members are control-plane users (which a shard can't enumerate), the way to add someone is an email invitation, not a user search; the control-plane page remains the place for global admins to add existing control-plane users directly.
Accepting WFP tenant-subdomain issuers
The control-plane resources above verify the caller's service token against the issuer's published JWKS, allowing iss to be either env.ISSUER or the host the request arrived on. A Workers-for-Platforms shard, however, signs with its own key and issuer (https://{tenant}.{host}/), which is neither — so by default such a token is rejected as an issuer mismatch.
Opt those issuers in with proxyControlPlane.isTrustedIssuer, a predicate that widens the accepted issuer set to a deployment's own tenant subdomains:
export default init({
proxyControlPlane: {
// …customDomains / tenantMembers / resolveHost as above.
isTrustedIssuer: (iss) => isOwnTenantSubdomain(iss), // e.g. *.auth.example.com
},
});It is consulted before any JWKS fetch — so it still constrains where verifying keys are fetched from — the signature must still verify against the resolved key, and tenant_id is still pinned. Return true only for issuer hosts you actually serve. The predicate applies to every mounted resource (custom-domains, tenant-members, sync).
Verifying without reaching the shard
Pair isTrustedIssuer with a proxyControlPlane.jwksFetch that resolves each tenant's control-plane-comm public key from a local registry instead of the network, so the control plane never has to fetch keys from — or dispatch to — the shard at verify time. Provisioning the per-tenant credential and registry is deployment wiring; see issue #1139.
Proxy entity sync
proxy_routes are tenant-owned data that the control plane needs for host resolution, so they still replicate upward over the outbox. (Custom domains used to travel this way too — that is exactly what left them registered nowhere. They now write through the control plane instead, as described above.)
Wire shape
Every successful create/update/delete on a tenant shard enqueues a controlplane.sync.{entity}.{op} outbox event. ControlPlaneSyncDestination POSTs each event to:
POST /api/v2/proxy/control-plane/sync
Authorization: Bearer <service token, scope=controlplane:sync>
Idempotency-Key: <event_id>
Content-Type: application/json
{
"events": [
{
"event_id": "...",
"tenant_id": "acme",
"entity": "proxy_route",
"op": "created", // or "updated" / "deleted"
"aggregate_id": "...",
"payload": { /* full row */ },
"occurred_at": "2026-06-05T12:34:56.789Z"
}
]
}The receiver responds 204 No Content on success.
Receiver configuration
The control-plane instance opts in to /sync by providing proxyControlPlane.applySyncEvents. createApplySyncEvents wires an idempotent adapter-backed receiver:
import { init, createApplySyncEvents } from "authhero";
import createAdapters from "@authhero/kysely-adapter";
const proxyAdapters = createAdapters(proxyDb);
export default init({
dataAdapter: proxyAdapters,
proxyControlPlane: {
resolveHost: (host) =>
proxyAdapters.customDomains.resolveHost?.(host) ?? null,
jwksUrl: `${env.ISSUER}/.well-known/jwks.json`,
// jwksFetch: (url) => env.JWKS_SERVICE.fetch(url), // optional, Workers
applySyncEvents: createApplySyncEvents({
proxyRoutes: proxyAdapters.proxyRoutes,
}),
},
});The endpoint is cross-tenant. Authhero authenticates the bearer JWT internally: tokens must be signed by a key in jwksUrl, carry an iss matching env.ISSUER, and include the proxy:resolve_host scope. Never issue tenant tokens to the proxy — mint a dedicated M2M client whose grant includes the proxy:resolve_host scope.
Idempotency
The outbox retries on network failure, so the receiver MUST be safe to call multiple times. createApplySyncEvents handles the three retry shapes:
- Duplicate
created— falls back toupdateon the existing row. updatedfor a row that doesn't exist locally yet — falls back tocreate(the source-shardidis preserved when the adapter supports it).deletedfor a row that's already gone — no-op success.
Adapters that don't preserve the source-shard id will diverge over time; the kysely and drizzle adapters both use input.id when supplied (proxyRouteInsertSchema carries it as optional).
Publishing resolved hosts to Cloudflare KV
To take the two-hop HTTP read off the proxy's hot path, the control plane can publish each resolved host blob to a Cloudflare KV namespace that the proxy reads directly. Wrap the customDomains + proxyRoutes adapters once with wrapProxyAdaptersWithKvPublish and pass the wrapped pair to both the dataAdapter (direct management-API writes) and createApplySyncEvents (/sync-replicated writes) — the adapter layer is the single choke-point that sees every write regardless of origin. Recompute-and-publish runs fire-and-forget via ctx.waitUntil, so it never blocks or fails the originating write.
This is the write side of the proxy's KV read replica; the full publish → seed → use guide (including backfillProxyHostsToKv and the proxy-side createKvProxyAdapter) lives in Proxy → Shape 3b.
Tenant shard configuration
Each tenant shard opts into replication with the controlPlaneSync block on AuthHeroConfig (requires the outbox to be enabled):
import { init } from "authhero";
export default init({
dataAdapter,
outbox: { enabled: true },
controlPlaneSync: {
baseUrl: "https://controlplane.example.com",
},
});The same destination is also wired into createDefaultDestinations({ controlPlaneSync }) so cron-drained deliveries don't lose events that missed per-request processing.
Audit-log filtering
controlplane.sync.* events are filtered out of LogsDestination and LogStreamDestination by event-type prefix, so replication traffic does not pollute audit logs or downstream log streams.
When to skip it
Single-database deployments — where the proxy reads from the same database the management API writes to — leave controlPlaneSync and proxyControlPlane.applySyncEvents unset. No replication is needed.
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
Within the multi-tenancy package:
- Tenant Lifecycle - Learn about creating and managing tenants
- Database Isolation - Set up per-tenant databases
- Runtime Fallback - Inherit configuration from control plane at runtime
- Control Plane Defaults (WFP) - Project defaults and shared secrets into per-tenant databases — the Workers for Platforms variant of this control-plane model
- API Reference - Complete API documentation
How the control plane connects to the proxy, custom domains, and WFP tenants:
- Multi-Tenancy architecture - the map that ties the control plane, proxy, custom domains, and WFP tenants together
- Proxy package - the data plane that resolves custom domains and dispatches to tenants (see Proxy entity sync above for how routes reach it)
- Custom Domain Setup - DNS, TLS, and edge routing for the customer domains this control plane publishes
- Cloudflare Workers for Platforms - the isolated per-tenant deployment this control plane feeds