Database Isolation
Each tenant can have its own database instance for complete data isolation. The multi-tenancy package provides a DatabaseFactory interface that can be implemented for different database providers.
Overview
Database isolation ensures that:
- Each tenant's data is stored in a separate database
- No data can leak between tenants
- Databases can be independently backed up and migrated
- Performance is isolated (one tenant's load doesn't affect others)
Database Factory Interface
interface DatabaseFactory {
// Get data adapters for a specific tenant
getAdapters(tenantId: string): Promise<DataAdapters>;
// Provision a new database for a tenant
provision(tenantId: string): Promise<void>;
// Deprovision (delete) a tenant's database
deprovision(tenantId: string): Promise<void>;
}Cloudflare D1
For Cloudflare D1 databases, use the factory from @authhero/cloudflare:
import { setupMultiTenancy } from "@authhero/multi-tenancy";
import { createD1Factory, createD1Adapter } from "@authhero/cloudflare";
const d1Factory = createD1Factory({
accountId: env.CF_ACCOUNT_ID,
apiToken: env.CF_API_TOKEN,
databasePrefix: "tenant_", // Creates databases like "tenant_acme"
createAdapters: (db) => createD1Adapter(db),
});
const multiTenancy = setupMultiTenancy({
accessControl: {
mainTenantId: "main",
},
databaseIsolation: {
getAdapters: d1Factory.getAdapters,
onProvision: async (tenantId) => {
await d1Factory.provision(tenantId);
// Run migrations
await runMigrations(tenantId);
},
onDeprovision: d1Factory.deprovision,
},
});How D1 Factory Works
- Provisioning: Creates a new D1 database using the Cloudflare API
- getAdapters: Returns data adapters connected to the tenant's database
- Deprovisioning: Deletes the tenant's D1 database
Turso / LibSQL
For Turso databases, implement a factory in your application:
import { setupMultiTenancy, DatabaseFactory } from "@authhero/multi-tenancy";
import { createClient } from "@libsql/client";
function createTursoFactory(config: {
organizationName: string;
apiToken: string;
}) {
const factory: DatabaseFactory = {
async getAdapters(tenantId: string) {
const url = `libsql://tenant-${tenantId}-${config.organizationName}.turso.io`;
const client = createClient({
url,
authToken: config.apiToken,
});
return createTursoAdapter(client);
},
async provision(tenantId: string) {
// Call Turso API to create database
const response = await fetch(
`https://api.turso.tech/v1/organizations/${config.organizationName}/databases`,
{
method: "POST",
headers: {
Authorization: `Bearer ${config.apiToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: `tenant-${tenantId}`,
group: "default",
}),
},
);
if (!response.ok) {
throw new Error(
`Failed to create Turso database: ${await response.text()}`,
);
}
},
async deprovision(tenantId: string) {
// Call Turso API to delete database
await fetch(
`https://api.turso.tech/v1/organizations/${config.organizationName}/databases/tenant-${tenantId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${config.apiToken}`,
},
},
);
},
};
return factory;
}
// Usage
const tursoFactory = createTursoFactory({
organizationName: "my-org",
apiToken: env.TURSO_API_TOKEN,
});
const multiTenancy = setupMultiTenancy({
databaseIsolation: {
getAdapters: tursoFactory.getAdapters,
onProvision: tursoFactory.provision,
onDeprovision: tursoFactory.deprovision,
},
});Custom Database Provider
Implement your own factory for any database:
import { DatabaseFactory, setupMultiTenancy } from "@authhero/multi-tenancy";
const customFactory: DatabaseFactory = {
async getAdapters(tenantId: string) {
// Connect to the tenant's database
const connection = await connectToDatabase({
host: `tenant-${tenantId}.db.example.com`,
user: env.DB_USER,
password: env.DB_PASSWORD,
});
// Return data adapters
return createMyAdapter(connection);
},
async provision(tenantId: string) {
// 1. Create database
await createDatabase(`tenant_${tenantId}`);
// 2. Run migrations
await runMigrations(tenantId);
// 3. Seed default data
await seedDefaultData(tenantId);
// 4. Configure backups
await configureBackups(tenantId);
},
async deprovision(tenantId: string) {
// 1. Create final backup
await backupDatabase(tenantId);
// 2. Archive data
await archiveData(tenantId);
// 3. Delete database
await deleteDatabase(`tenant_${tenantId}`);
},
};
const multiTenancy = setupMultiTenancy({
databaseIsolation: {
getAdapters: customFactory.getAdapters,
onProvision: customFactory.provision,
onDeprovision: customFactory.deprovision,
},
});Configuration Options
Basic Setup
Minimal configuration for database isolation:
const multiTenancy = setupMultiTenancy({
databaseIsolation: {
getAdapters: async (tenantId) => {
const db = await getTenantDatabase(tenantId);
return createAdapter(db);
},
},
});With Provisioning
Add provisioning for automatic database creation:
const multiTenancy = setupMultiTenancy({
databaseIsolation: {
getAdapters: factory.getAdapters,
onProvision: async (tenantId) => {
// Create database
await factory.provision(tenantId);
// Run migrations
await runMigrations(tenantId);
// Seed data
await seedData(tenantId);
},
},
});When the control plane carries the tenant-operations adapters, the provision run is recorded as a durable operation with step events (see Tenant Operations). Two extra knobs on databaseIsolation control this:
onProvision(tenantId, report?)— the optional second argument is aStepReporter; call it at step boundaries (await report?.("run-migrations", "succeeded")) to surface per-step events in the operation history. Ignoring it records a single coarse step.recordProvisionOperations: false— opt out of the automatic recording when the hook manages its own operation rows (the Cloudflare Workflows hook does this).
With Deprovisioning
Add cleanup when tenants are deleted:
const multiTenancy = setupMultiTenancy({
databaseIsolation: {
getAdapters: factory.getAdapters,
onProvision: factory.provision,
onDeprovision: async (tenantId) => {
// Backup before deletion
await backupDatabase(tenantId);
// Delete database
await factory.deprovision(tenantId);
// Clean up related resources
await cleanupResources(tenantId);
},
},
});Middleware Integration
The database middleware automatically resolves and injects the correct database adapters:
import { createDatabaseMiddleware } from "@authhero/multi-tenancy";
const dbMiddleware = createDatabaseMiddleware({
databaseIsolation: {
getAdapters: factory.getAdapters,
},
});
app.use("*", dbMiddleware);
app.route("/", authheroApp);The middleware:
- Reads the tenant ID from
ctx.var.tenant_id. Mounted in front of AuthHero, that is only set if something resolved it already, such as subdomain routing or your own middleware. Without it the request fails with 400. - Calls
getAdapters(tenantId)to get the tenant's database - Installs the adapters as the request's base adapter (
ctx.var.baseData) - Every AuthHero route group (auth API, universal login, u2, SAML, SCIM, management API) builds its per-request adapter stack (caching, hooks, client bundle) on top of that base instead of the
dataAdapterpassed toinit(). Outbox events and logs for the request go to the same database.
getAdapters replaces both dataAdapter and managementDataAdapter for the request. If you rely on wrappers such as runtime fallback from the control plane, apply them to the adapters you return.
Mount the middleware before AuthHero's routes. Hono runs middleware in registration order, so middleware registered after the routes never runs for them.
INFO
Before @authhero/multi-tenancy 15.1 the middleware only wrote ctx.env.data, which AuthHero's route groups replaced with a stack built from the startup dataAdapter. Those routes read and wrote the shared database, not the tenant's. The middleware also wrote to the env object itself, which on Cloudflare Workers is shared by every request in an isolate. It now works on a per-request copy.
Best Practices
1. Connection Pooling
Implement connection pooling to avoid creating new connections on every request:
const connectionPool = new Map<string, DataAdapters>();
const factory: DatabaseFactory = {
async getAdapters(tenantId: string) {
// Check cache first
if (connectionPool.has(tenantId)) {
return connectionPool.get(tenantId)!;
}
// Create and cache connection
const adapters = await createConnection(tenantId);
connectionPool.set(tenantId, adapters);
return adapters;
},
// ...
};2. Migration Management
Track migration status per tenant:
async function runMigrations(tenantId: string) {
const db = await getTenantDatabase(tenantId);
// Check current migration version
const currentVersion = await db.getMigrationVersion();
// Run pending migrations
const migrations = await getPendingMigrations(currentVersion);
for (const migration of migrations) {
await db.runMigration(migration);
}
}3. Backup Strategy
Implement regular backups:
async function provision(tenantId: string) {
await createDatabase(tenantId);
// Schedule daily backups
await scheduleBackup(tenantId, {
frequency: "daily",
retention: 30,
});
}4. Error Handling
Handle database errors gracefully:
const factory: DatabaseFactory = {
async getAdapters(tenantId: string) {
try {
return await connectToDatabase(tenantId);
} catch (error) {
console.error(
`Failed to connect to database for tenant ${tenantId}:`,
error,
);
// Fall back to read-only replica
return await connectToReadReplica(tenantId);
}
},
// ...
};Performance Considerations
Lazy Loading
Databases are only connected when needed:
// Database connection happens on first request
app.get("/api/users", async (c) => {
// c.env.data is populated by middleware
const users = await c.env.data.users.list();
return c.json(users);
});Caching
Cache database connections to avoid repeated setup:
const cache = new LRUCache<string, DataAdapters>({
max: 100,
ttl: 1000 * 60 * 60, // 1 hour
});
const factory: DatabaseFactory = {
async getAdapters(tenantId: string) {
const cached = cache.get(tenantId);
if (cached) return cached;
const adapters = await createConnection(tenantId);
cache.set(tenantId, adapters);
return adapters;
},
// ...
};Read Replicas
Use read replicas for better performance:
const factory: DatabaseFactory = {
async getAdapters(tenantId: string) {
return {
// Write operations go to primary
...(await connectToPrimary(tenantId)),
// Read operations go to replica
users: await connectToReplica(tenantId, "users"),
logs: await connectToReplica(tenantId, "logs"),
};
},
// ...
};Next Steps
- Tenant Lifecycle - Learn about tenant creation and deletion
- Runtime Fallback - Inherit configuration from control plane at runtime
- API Reference - Complete API documentation