Runtime Fallback Adapter
The Runtime Fallback Adapter provides runtime fallback functionality from a control plane tenant to child tenants. This allows you to set up default configurations that child tenants can inherit while still allowing tenant-specific customizations.
Complementary Approaches
This adapter provides runtime value fallback (sensitive data stays in control plane), while Entity Sync (see Entity Synchronization) copies entities to child tenants (needed for foreign key references).
Overview
In multi-tenant applications, you often want to provide common defaults across all tenants while allowing individual tenants to override specific settings. The Runtime Fallback Adapter solves this by:
- Providing Fallbacks at Query Time: Values are merged when data is retrieved
- Protecting Sensitive Data: Connection secrets, API keys remain in control plane only
- Allowing Overrides: Tenant-specific values always take precedence
- Transparent Integration: Works seamlessly with existing data adapters
Runtime Fallback vs Entity Sync
| Feature | Runtime Fallback | Entity Sync |
|---|---|---|
| When applied | At query time | At creation/update time |
| Sensitive data | Stays in control plane | Stripped before copying |
| Use case | Connection secrets, SMTP keys, default URLs | Resource servers, roles (foreign keys) |
| Storage | Single copy in control plane | Copied to each tenant |
| Updates | Immediate (next query) | Requires sync operation |
Features
Client Fallbacks
- Merges
web_origins,allowed_logout_urls, andcallbacksarrays - Inherits tenant properties from control plane client
- Preserves tenant-specific overrides
Connection Fallbacks
- Merges connection options from control plane connections
- Matches connections by strategy (e.g., "google", "email", "facebook")
- Supports deep merging of connection options objects
- Enables shared social connections without exposing client secrets to individual tenants
Strategy-Based Matching
Connections are matched by strategy rather than by name. This means a tenant can create a connection with strategy "google" and leave the OAuth keys blank - the system will automatically inherit credentials from the control plane's Google connection.
Shared Social Authentication
One of the most powerful features is the ability to provide social authentication (Google, Facebook, Apple, etc.) to all tenants without sharing sensitive client secrets:
- Centralized OAuth Apps: Configure OAuth applications once in the control plane
- Secret Protection: Client secrets remain secure in the control plane only
- Automatic Availability: Social connections become available to all tenants automatically
- Tenant Customization: Individual tenants can still override settings like scopes or branding
Usage
Basic Setup
import { withRuntimeFallback } from "@authhero/multi-tenancy";
import createAdapters from "@authhero/kysely-adapter";
// Create your base data adapters
const db = // ... your database connection
const baseAdapters = createAdapters(db);
// Wrap with runtime fallback functionality
const adapters = withRuntimeFallback(baseAdapters, {
controlPlaneTenantId: "main", // Control plane tenant for defaults
controlPlaneClientId: "main-client" // Control plane client for defaults
});
// Use with AuthHero init
import { init } from "authhero";
const { app } = init({
dataAdapter: adapters
});Environment-Based Configuration
You can configure the runtime fallback adapter using environment variables:
import { withRuntimeFallback } from "@authhero/multi-tenancy";
const adapters = withRuntimeFallback(baseAdapters, {
controlPlaneTenantId: process.env.CONTROL_PLANE_TENANT_ID,
controlPlaneClientId: process.env.CONTROL_PLANE_CLIENT_ID,
});Combined with Multi-Tenancy Features
import { init, withRuntimeFallback } from "@authhero/multi-tenancy";
// First, wrap adapters with runtime fallback
const baseAdapters = createAdapters(db);
const fallbackAdapters = withRuntimeFallback(baseAdapters, {
controlPlaneTenantId: "control_plane",
controlPlaneClientId: "control_plane_client",
});
// Then use with multi-tenancy init for entity sync
const { app } = init({
dataAdapter: fallbackAdapters,
controlPlaneTenantId: "control_plane",
sync: {
resourceServers: true,
roles: true,
},
});How It Works
Client Retrieval Flow
When a client is retrieved:
- Fetches the client from the database
- Fetches control plane client (if
controlPlaneClientIdis configured) - Merges arrays (
web_origins,allowed_logout_urls,callbacks) - Merges tenant properties with control plane fallbacks
- Fetches and merges connections for the client
Connection Fallback Flow
When connections are retrieved:
- Fetches tenant's connections from the database
- Fetches control plane connections (if
controlPlaneTenantIdis configured) - Matches connections by strategy (e.g., "google", "email")
- Merges connection options - tenant values override control plane values
- Returns merged connections
Fallback Priority
Settings are merged with tenant-specific values always taking precedence:
// Control plane connection
{
name: "email",
strategy: "email", // Connections matched by strategy
options: {
from: "[email protected]",
smtp_host: "smtp.example.com",
smtp_user: "[email protected]",
smtp_password: "secret123" // Sensitive!
}
}
// Tenant's connection (override from address only)
{
name: "my-email", // Name can be different
strategy: "email", // Same strategy = will inherit
options: {
from: "[email protected]"
// smtp_password is inherited from control plane
}
}
// Merged result (returned to tenant)
{
name: "my-email", // Tenant's name preserved
strategy: "email",
options: {
from: "[email protected]", // Tenant override
smtp_host: "smtp.example.com", // Fallback
smtp_user: "[email protected]", // Fallback
smtp_password: "secret123" // Fallback (stays secure)
}
}Common Patterns
Shared Social Connections
Set up social authentication once in the control plane:
// In control plane tenant
POST /api/v2/connections
{
"name": "google-oauth2",
"strategy": "google-oauth2",
"options": {
"client_id": "your-google-client-id",
"client_secret": "your-google-client-secret",
"scope": ["openid", "email", "profile"]
}
}
// Child tenants just need to define the strategy - keys are inherited!
POST /api/v2/connections // In child tenant
{
"name": "google", // Name can be anything
"strategy": "google-oauth2", // Same strategy = inherits credentials
"display_name": "Sign in with Google for Acme Corp",
"options": {
"scope": ["openid", "email", "profile", "calendar"] // Optional overrides
// client_id and client_secret automatically inherited from control plane!
}
}Minimal Configuration
Tenants can simply create a connection with the desired strategy and leave sensitive fields blank. The runtime fallback will automatically provide the OAuth credentials from the control plane.
Shared Email Provider
Set up email provider once in control plane:
// In control plane
POST /api/v2/email-providers
{
"name": "smtp",
"credentials": {
"smtp_host": "smtp.sendgrid.net",
"smtp_port": 587,
"smtp_user": "apikey",
"smtp_password": "SG.xxx..."
}
}
// Child tenants inherit SMTP settings
// They can override from address per tenant
PATCH /api/v2/tenants/{id}
{
"sender_email": "[email protected]",
"sender_name": "Acme Corp"
}Default Client URLs
Provide default callback URLs from control plane:
// Control plane client
{
"client_id": "control_plane_client",
"callbacks": [
"http://localhost:3000/callback",
"https://dev.example.com/callback"
]
}
// Tenant client inherits and extends
{
"client_id": "tenant_client",
"callbacks": [
"https://tenant.com/callback"
]
}
// Merged result includes both
{
"client_id": "tenant_client",
"callbacks": [
"http://localhost:3000/callback", // From control plane
"https://dev.example.com/callback", // From control plane
"https://tenant.com/callback" // From tenant
]
}API Reference
createRuntimeFallbackAdapter(baseAdapters, config)
Creates a wrapped adapter with runtime fallback functionality.
Parameters:
baseAdapters: DataAdapters- The base data adapters to wrapconfig: RuntimeFallbackConfig- Configuration object
Returns: DataAdapters - Wrapped adapters with fallback functionality
withRuntimeFallback(baseAdapters, config)
Convenience helper for createRuntimeFallbackAdapter.
Parameters:
baseAdapters: DataAdapters- The base data adapters to wrapconfig: RuntimeFallbackConfig- Configuration object
Returns: DataAdapters - Wrapped adapters with fallback functionality
RuntimeFallbackConfig
Configuration interface for runtime fallback:
interface RuntimeFallbackConfig {
controlPlaneTenantId?: string; // Control plane tenant ID for connection fallbacks
controlPlaneClientId?: string; // Control plane client ID for client fallbacks
resolveControlPlane?: ControlPlaneResolver; // Per-tenant override (see below)
}
type ControlPlaneResolver = (params: {
tenant_id: string;
}) =>
| { tenantId: string; clientId?: string }
| undefined
| Promise<{ tenantId: string; clientId?: string } | undefined>;Per-Tenant Control of Inheritance
When resolveControlPlane is provided, it replaces the static controlPlaneTenantId / controlPlaneClientId fields for inheritance lookups only. Every wrapped adapter read (connections, hooks, resource servers, email providers) calls the resolver with the tenant being read, and the returned {tenantId, clientId} decides which tenant to fall back to. Returning undefined opts a specific tenant out of inheritance entirely — its reads see only its own rows.
import { withRuntimeFallback } from "@authhero/multi-tenancy";
const ISOLATED_TENANTS = new Set(["partner_a", "partner_b"]);
const adapters = withRuntimeFallback(baseAdapters, {
controlPlaneTenantId: "main",
controlPlaneClientId: "main-client",
resolveControlPlane: ({ tenant_id }) =>
ISOLATED_TENANTS.has(tenant_id)
? undefined
: { tenantId: "main", clientId: "main-client" },
});Access control is unaffected
The static controlPlaneTenantId is still used by the tenants management API to validate cross-tenant access. Only inheritance lookups (connection secrets, inheritable hooks, is_system resource server scopes, email providers) consult the resolver.
Per-Tenant Sync Mode (config_sync)
Each tenant has a config_sync setting:
| Value | Behaviour |
|---|---|
inherit-on-read (default, also when unset) | The control plane's connections, hooks, email provider and system API scopes are merged in at request time, as described on this page. |
synced | Only the tenant's own database is read. Nothing is merged from the control plane at request time. |
synced is for tenants whose config has been copied into their own database (see syncing a resolved parent chain), for example a tenant that moves to Workers for Platforms and can no longer read the control plane's database per request. The mode applies everywhere read-time inheritance does: the auth and management adapters, client URL merging, inherited actions and control-plane service tokens.
# With a control-plane token: switch a tenant to synced, and back.
curl -X PATCH https://auth.example.com/api/v2/tenants/settings \
-H "authorization: Bearer $CONTROL_PLANE_TOKEN" \
-H "tenant-id: tenant-1" -H "content-type: application/json" \
-d '{"config_sync": "synced"}'The admin UI exposes the same switch under Settings → Advanced.
- Who can change it: only a control-plane credential. A tenant's own admin sees the value, can send it back unchanged, but gets
403when trying to change it, because flipping back toinherit-on-readturns read-time inheritance on again. In a deployment without a control plane the check is skipped. A tenant created withconfig_syncset keeps it only when the creator is a control-plane credential. - Rollback: set it back to
inherit-on-read. Nothing is written to the control plane by either mode, so its copy is untouched and inheritance resumes. The synced rows (syids) are left in the tenant and are not updated while it inherits on read; see the runbook for what that means. - Eventual consistency: each isolate remembers a tenant's mode for 10 seconds by default, and the instance that handles the change forgets it at once. Set
configSyncCacheTtlMsoninitMultiTenantto change it: lower is faster, at the cost of one moretenants.getper isolate and tenant per period. That is how long an isolate keeps the old mode, not how soon logins change: when the auth server runs with a cache, the client bundle (a tenant's client, connections and settings, read through the inheritance layer) is kept for 300 seconds and may be served stale for up to 600 more, and purging it on a tenant update is best effort (it cannot enumerate keys in the Cloudflare Cache API). Count on up to 15 minutes there, or purge the cache. If the lookup fails the tenant keeps inheriting; a failed lookup never breaks a request. - Before you flip: make sure the tenant's database already holds what it used to inherit, and use shadow mode to compare. The runbook walks through it.
inherit: trueaction stubs only resolve against the control plane while the tenant isinherit-on-read, and client URLs from the control plane's client are no longer merged insyncedmode. - Shadow mode skips
syncedtenants, since comparing them with read-time inheritance would only report differences.
Shadow Mode for Config Inheritance
Tenant config inheritance (parent tenants and per-row scope, override and enforce) is being introduced step by step. Shadow mode lets you see how the new parent-chain resolver would differ from the runtime fallback described on this page, before anything depends on it.
initMultiTenant({
dataAdapter,
controlPlane: { tenantId: "main", clientId: "default" },
inheritanceShadow: true, // or { sample: 0.1, intervalMs: 60_000 }
});When enabled, wrapped reads also start a background comparison per tenant. The resolver walks parent_tenant_id (a tenant without a parent uses the control plane as its implicit parent), merges the chain, and compares the result with what this adapter returns. Differences are written to the tenant's logs with type cfgdiff:
GET /api/v2/logs?q=type:cfgdiff- The comparison never changes a response, and failures are swallowed.
- Each log has the entity, key, kind (
missing,extra,changed) and the names of the differing fields. Secret values are never logged. - Rows the new model would copy into a tenant but the fallback only overlays are
expectedand are skipped unlessincludeExpected: true. - Comparisons are limited to one per tenant per
intervalMs(default 60 s) and repeated differences are logged once per hour.
In a tenant worker
A tenant with its own worker and database reads the control-plane rows that were projected into its own database, so the comparison can run there too. Pass inheritanceShadow to createWfpTenantApp:
createWfpTenantApp({
createDataAdapter: (env) => createAdapters(drizzle(env.AUTH_DB)),
inheritanceShadow: true, // or { sample: 0.1, intervalMs: 60_000 }
});It runs after the response has been built, under ctx.waitUntil, so a Worker keeps it alive and the request never waits for it. Calls to /internal/* (the control plane's own pushes) are not compared.
The worker only holds the tenant's own row and the control-plane rows projected into its database, so it compares against those. If the tenant's row names a parent that this database lacks, the comparison is skipped rather than reporting rows as missing that the full chain would supply. A tenant under an intermediate parent is checked in full by Verify on the control plane, which resolves the whole chain. The cfgdiff logs are written to the tenant's own logs, which the control plane reads through the usual logs API for that tenant.
Migration from @authhero/authhero
If you were using the deprecated withMainTenantFallback from @authhero/authhero:
Before (Deprecated)
import { withMainTenantFallback } from "@authhero/authhero";
const adapters = withMainTenantFallback(baseAdapters, {
mainTenantId: "main",
mainClientId: "main-client",
});After (Current)
import { withRuntimeFallback } from "@authhero/multi-tenancy";
const adapters = withRuntimeFallback(baseAdapters, {
controlPlaneTenantId: "main", // renamed from mainTenantId
controlPlaneClientId: "main-client", // renamed from mainClientId
});The functionality is identical, only the naming has changed to better reflect the control plane/child tenant model.
Best Practices
- Use for Sensitive Data: Perfect for connection secrets, SMTP credentials, OAuth keys
- Combine with Entity Sync: Use sync for resource servers/roles, fallback for secrets
- Document Overrides: Clearly document which settings can be overridden by tenants
- Test Fallbacks: Verify fallback behavior works as expected in development
- Monitor Usage: Track which tenants use fallback vs custom configs
Troubleshooting
Fallbacks Not Working
Check that:
controlPlaneTenantIdandcontrolPlaneClientIdare set correctly- Control plane tenant/client exists in database
- Connection strategies match between control plane and child tenant (e.g., both use "google-oauth2")
- Adapter is properly wrapped before use
Wrong Values Being Used
Remember:
- Tenant values always override control plane values
- Arrays are merged (both control plane and tenant values included)
- Null/empty tenant values don't trigger fallback - only missing properties do
See Also
- Entity Synchronization - Sync resource servers and roles to child tenants
- Control Plane - Understanding the control plane model