Summary
This proposes adding an isolated-database multi-tenant runtime to Supabase Auth. The goal is to let one Auth process resolve a tenant per request, synthesize tenant-specific runtime configuration, route data access to a bounded tenant database pool, and support hot tenant configuration updates without process restart.
This proposal intentionally keeps shared_instance_id blocked. Many Auth tables and state flows still lack full tenant-scoped predicates or instance_id columns, so shared-DB mode should not be enabled until table-level scoping is complete and audited end to end.
Proposed design
-
Control-plane tenant metadata
- Add an
auth_tenants control-plane table containing tenant ID, instance_id, tenant DB URL/namespace, JWT secret/service key, external URL, site URL, redirect allow-list, JSON runtime config, and disabled_at.
- Tenant metadata cache is bounded by item count and byte budget, has TTL, uses per-tenant singleflight for cold loads, and supports PostgreSQL
LISTEN/NOTIFY targeted invalidation and wildcard cache clearing.
- Control-plane SQL pool has its own max-open/max-idle limits.
-
Tenant resolution
- Resolve tenant from a configured header, forwarded-host regexp, or default tenant ID.
- Missing, unknown, disabled, host-confused, and conflicting tenant resolvers fail closed before user auth.
- Invalid forwarded-host regexp fails startup validation.
-
Request-scoped config
- Add
conf.WithRequestConfig / conf.FromContext and update tenant-sensitive paths to read request config instead of process config.
- Tenant JSON can override JWT issuer/audience/expiration/default group, URL/redirect policy, provider feature gates, OAuth server, custom OAuth, passkey/WebAuthn, SAML/SSO, SMTP, SMS, hooks, MFA, password/session/flow-state policy, request rate limits, and email/SMS send-rate limits.
- Tenant metadata URL/glob/config validation fails closed during tenant resolution.
- A bounded last-known-good runtime config fallback is only allowed when tenant ID, instance ID, DB URL/namespace, JWT secret, and service key are unchanged. Disabled/missing tenants, first-load failures, and DB/JWT/service-key boundary changes fail closed.
-
Isolated tenant DB routing
- Add a request-scoped
TenantDatabase and bounded storage.TenantRouter.
- Tenant DB pools are opened lazily and capped separately from metadata cache size.
- Routing keys include driver, URL, and namespace.
- In-use or closing pools continue counting against capacity until released; new tenant routes wait or fail with request context timeout instead of opening over budget.
- Startup config requires
DatabasePoolConnectionBudget covering tenant request pools, control-plane SQL pool, optional notify listener, and enabled tenant cleanup/index worker concurrency.
-
Hot updates
- Tenant config changes, JWT secret rotation, DB metadata changes, tenant disable/restore, and URL/redirect policy changes are applied through cache invalidation plus request-time reload.
- Multi-node behavior relies on each Auth process maintaining its own cache and listening for invalidation notifications.
- Control-plane short outage behavior is fail-closed for cold misses and for cached entries after TTL expiry.
-
Background workers and global object lifecycle
- Index and cleanup workers page enabled tenants from the control plane and process isolated DBs with configurable concurrency.
- One tenant worker failure is recorded but does not cancel the whole tenant fleet scan.
- Tenant cleanup connections use the tenant dial path and skip per-pool DB stats callbacks / DB advisor goroutines.
- Per-tenant cleanup runners skip long-lived cleanup metric callbacks.
- OIDC provider cache and mailer template cache are bounded in multi-tenant mode.
- PostgreSQL function hooks use the supplied transaction directly and avoid an extra process-level DB route when already inside a transaction.
Validation performed locally
The local branch includes a PoC under poc/multitenant and focused Go tests. On July 10, 2026, I ran:
bash -n over the multi-tenant PoC scripts
node --check poc/multitenant/browser-passkey-host.mjs
git diff --check
make auth
DATABASE_URL=postgres://supabase_auth_admin:root@localhost:5541/postgres GOTRUE_DB_DATABASE_URL=postgres://supabase_auth_admin:root@localhost:5541/postgres go test -p=1 -count=1 ./...
- host-mode PoC: 42/42 scenarios, ending with
PoC baseline completed
- browser passkey + MFA WebAuthn E2E using Chrome CDP virtual authenticator
- multi-node hot-update validation: 9/9 scenarios covering independent caches, config update notification, disabled tenant notification, listener reconnect, short control-plane outage, and TTL-expired fail-closed behavior
- low-budget churn validation with 256 distinct tenant DB routing keys,
DatabasePoolMaxPools=8, DatabasePoolMaxOpen=5, DatabasePoolConnectionBudget=59: 256 public anonymous signup/user/refresh requests, 0 errors, p99 about 2499 ms, and tenant DB pg_stat_activity showing 8 churn tenant idle connections plus 1 active psql sampling query
Earlier local load runs also covered 10,000 tenant metadata cold/warm /settings, 10,000 public anonymous signup/user/refresh, 10,000 email/password signup/user/refresh, 10,000 admin create/get/list/update/delete, 10,000 admin generate_link signup links, and 10,000 password/user/refresh flows in the local shared physical Postgres PoC setup.
Known remaining gaps
- This is not yet a production-readiness claim.
- Official CI and target deployment environment validation are still required.
shared_instance_id remains unsafe and should stay rejected until table-level instance_id scoping is implemented and audited for every state table and query path.
- More 10k+ coverage is still needed for the remaining Auth write-path matrix and real production-like 10k independent DB topology.
- Real third-party OAuth providers, real external SMS/email delivery providers, and hardware or OS platform WebAuthn authenticators still need E2E validation.
- Production runbooks, alerts, auditability, long control-plane partition policy, and deployment-specific connection-budget sizing still need to be designed.
Summary
This proposes adding an isolated-database multi-tenant runtime to Supabase Auth. The goal is to let one Auth process resolve a tenant per request, synthesize tenant-specific runtime configuration, route data access to a bounded tenant database pool, and support hot tenant configuration updates without process restart.
This proposal intentionally keeps
shared_instance_idblocked. Many Auth tables and state flows still lack full tenant-scoped predicates orinstance_idcolumns, so shared-DB mode should not be enabled until table-level scoping is complete and audited end to end.Proposed design
Control-plane tenant metadata
auth_tenantscontrol-plane table containing tenant ID,instance_id, tenant DB URL/namespace, JWT secret/service key, external URL, site URL, redirect allow-list, JSON runtime config, anddisabled_at.LISTEN/NOTIFYtargeted invalidation and wildcard cache clearing.Tenant resolution
Request-scoped config
conf.WithRequestConfig/conf.FromContextand update tenant-sensitive paths to read request config instead of process config.Isolated tenant DB routing
TenantDatabaseand boundedstorage.TenantRouter.DatabasePoolConnectionBudgetcovering tenant request pools, control-plane SQL pool, optional notify listener, and enabled tenant cleanup/index worker concurrency.Hot updates
Background workers and global object lifecycle
Validation performed locally
The local branch includes a PoC under
poc/multitenantand focused Go tests. On July 10, 2026, I ran:bash -nover the multi-tenant PoC scriptsnode --check poc/multitenant/browser-passkey-host.mjsgit diff --checkmake authDATABASE_URL=postgres://supabase_auth_admin:root@localhost:5541/postgres GOTRUE_DB_DATABASE_URL=postgres://supabase_auth_admin:root@localhost:5541/postgres go test -p=1 -count=1 ./...PoC baseline completedDatabasePoolMaxPools=8,DatabasePoolMaxOpen=5,DatabasePoolConnectionBudget=59: 256 public anonymous signup/user/refresh requests, 0 errors, p99 about 2499 ms, and tenant DBpg_stat_activityshowing 8 churn tenant idle connections plus 1 active psql sampling queryEarlier local load runs also covered 10,000 tenant metadata cold/warm
/settings, 10,000 public anonymous signup/user/refresh, 10,000 email/password signup/user/refresh, 10,000 admin create/get/list/update/delete, 10,000 admin generate_link signup links, and 10,000 password/user/refresh flows in the local shared physical Postgres PoC setup.Known remaining gaps
shared_instance_idremains unsafe and should stay rejected until table-levelinstance_idscoping is implemented and audited for every state table and query path.