Migration Guide
Learn how to migrate from a single-tenant AuthHero setup to multi-tenant architecture.
Overview
This guide covers:
- Planning your multi-tenant migration
- Migrating existing data
- Updating application code
- Testing and deployment strategies
- Rolling back if needed
Before You Begin
Prerequisites
- Existing AuthHero installation
- Database backup
- Understanding of your current architecture
- Plan for tenant organization
Planning Checklist
- [ ] Identify which data belongs to which tenant
- [ ] Choose a main tenant ID
- [ ] Plan subdomain structure
- [ ] Decide on database isolation strategy
- [ ] Review settings to inherit
- [ ] Plan user migration strategy
- [ ] Test migration in staging environment
Migration Strategies
Strategy 1: Single Database with Tenant Scoping
Keep all tenants in one database, add tenant scoping.
Pros:
- Simplest migration
- No data movement required
- Lower infrastructure costs
Cons:
- No physical data isolation
- Potential performance impact at scale
- Shared resource limits
Best for:
- Small to medium deployments
- Tenants with similar usage patterns
- Cost-sensitive implementations
Strategy 2: Database Isolation
Move each tenant to its own database.
Pros:
- Complete data isolation
- Independent scaling
- Better performance isolation
- Easier compliance (data residency)
Cons:
- More complex infrastructure
- Higher costs
- Data migration required
Best for:
- Large deployments
- Tenants with varying workloads
- Strict compliance requirements
Step-by-Step Migration
Step 1: Install Multi-Tenancy Package
pnpm add @authhero/multi-tenancyStep 2: Backup Your Data
# PostgreSQL example
pg_dump -h localhost -U user -d authhero > backup-$(date +%Y%m%d).sql
# SQLite example
sqlite3 authhero.db ".backup backup-$(date +%Y%m%d).db"Step 3: Choose Your Main Tenant
Identify which existing tenant becomes the "main" tenant:
// This is typically your first/original tenant
const MAIN_TENANT_ID = "your-original-tenant-id";Step 4: Create Organizations
Create an organization on the main tenant for each existing tenant:
import { setupMultiTenancy } from "@authhero/multi-tenancy";
// For each existing tenant (except main)
const existingTenants = ["tenant-a", "tenant-b", "tenant-c"];
for (const tenantId of existingTenants) {
// Create organization on main tenant
await mainTenantAdapters.organizations.create(MAIN_TENANT_ID, {
id: generateId(),
name: tenantId,
display_name: await getTenantName(tenantId),
});
}Step 5: Update Application Code
Before (Single Tenant)
import { createAuthhero } from "authhero";
const app = createAuthhero({
dataAdapter: createAdapter(db),
});
export default app;After (Multi-Tenant)
import { Hono } from "hono";
import { createAuthhero } from "authhero";
import { setupMultiTenancy } from "@authhero/multi-tenancy";
const multiTenancy = setupMultiTenancy({
accessControl: {
mainTenantId: "main",
defaultPermissions: ["tenant:admin"],
},
});
const app = new Hono();
// Apply multi-tenancy middleware
app.use("*", multiTenancy.middleware);
// Mount tenant management
app.route("/management", multiTenancy.app);
// Mount AuthHero with hooks
app.route(
"/",
createAuthhero({
dataAdapter: createAdapter(db),
hooks: multiTenancy.hooks,
}),
);
export default app;Step 6: Update Client Applications
React Admin Changes
Before:
// Single tenant - no organization parameter
const { getAccessTokenSilently } = useAuth0();
const fetchUsers = async () => {
const token = await getAccessTokenSilently();
const response = await fetch("/api/v2/users", {
headers: { Authorization: `Bearer ${token}` },
});
return response.json();
};After:
// Multi-tenant - use organization parameter
const { getAccessTokenSilently } = useAuth0();
const tenantId = useParams().tenantId;
const fetchUsers = async () => {
const token = await getAccessTokenSilently({
authorizationParams: {
organization: tenantId,
},
});
const response = await fetch("/api/v2/users", {
headers: { Authorization: `Bearer ${token}` },
});
return response.json();
};Step 7: Migrate to Database Isolation (Optional)
If implementing database isolation:
7.1 Create Databases
import { createD1Factory } from "@authhero/cloudflare";
const factory = createD1Factory({
accountId: env.CF_ACCOUNT_ID,
apiToken: env.CF_API_TOKEN,
});
// Provision databases for existing tenants
for (const tenantId of existingTenants) {
await factory.provision(tenantId);
}7.2 Migrate Data
async function migrateTenantData(tenantId: string) {
// Get source data (from shared database)
const sourceDb = getSharedDatabase();
const targetDb = await getTenantDatabase(tenantId);
// Migrate users
const users = await sourceDb.query(
"SELECT * FROM users WHERE tenant_id = ?",
[tenantId],
);
for (const user of users) {
await targetDb.query("INSERT INTO users (...) VALUES (...)", [user]);
}
// Migrate applications
const apps = await sourceDb.query(
"SELECT * FROM applications WHERE tenant_id = ?",
[tenantId],
);
for (const app of apps) {
await targetDb.query("INSERT INTO applications (...) VALUES (...)", [app]);
}
// Migrate other tables...
}
// Run migration for each tenant
for (const tenantId of existingTenants) {
console.log(`Migrating ${tenantId}...`);
await migrateTenantData(tenantId);
console.log(`✓ ${tenantId} migrated`);
}7.3 Verify Migration
async function verifyMigration(tenantId: string) {
const sourceDb = getSharedDatabase();
const targetDb = await getTenantDatabase(tenantId);
// Count records in source
const sourceUsers = await sourceDb.query(
"SELECT COUNT(*) FROM users WHERE tenant_id = ?",
[tenantId],
);
// Count records in target
const targetUsers = await targetDb.query("SELECT COUNT(*) FROM users");
if (sourceUsers[0].count !== targetUsers[0].count) {
throw new Error(`User count mismatch for ${tenantId}`);
}
console.log(`✓ ${tenantId} verification passed`);
}7.4 Switch to New Databases
const multiTenancy = setupMultiTenancy({
accessControl: {
mainTenantId: "main",
},
databaseIsolation: {
getAdapters: factory.getAdapters,
onProvision: factory.provision,
onDeprovision: factory.deprovision,
},
});Step 8: Update Tenant Management
Move from in-tenant management to centralized:
Before:
// Managing tenants within the same database
POST / api / v2 / tenants;
GET / api / v2 / tenants;After:
// Managing tenants from main tenant
POST / management / tenants;
GET / management / tenants;
// Tenant-scoped settings still in tenant context
GET / api / v2 / tenants / settings;
PATCH / api / v2 / tenants / settings;Testing Migration
Test Plan
- Unit Tests: Verify each component works in isolation
- Integration Tests: Test the full flow
- Load Tests: Ensure performance is acceptable
- User Acceptance Testing: Have users test critical flows
Test Checklist
- [ ] Users can log in to all tenants
- [ ] Tenant isolation is enforced
- [ ] Data is accessible from correct tenant
- [ ] Cross-tenant access is blocked
- [ ] Settings inheritance works
- [ ] Subdomain routing works (if configured)
- [ ] Tenant creation/deletion works
- [ ] Performance is acceptable
Sample Tests
import { describe, it, expect } from "vitest";
describe("Multi-Tenancy", () => {
it("should isolate tenant data", async () => {
// Create users in different tenants
await createUser("tenant-a", { email: "user@a.com" });
await createUser("tenant-b", { email: "user@b.com" });
// Verify isolation
const tenantAUsers = await getUsers("tenant-a");
expect(tenantAUsers).toHaveLength(1);
expect(tenantAUsers[0].email).toBe("user@a.com");
const tenantBUsers = await getUsers("tenant-b");
expect(tenantBUsers).toHaveLength(1);
expect(tenantBUsers[0].email).toBe("user@b.com");
});
it("should enforce organization-based access", async () => {
// Get token for tenant-a
const token = await getToken({ org_id: "tenant-a" });
// Should access tenant-a
const response = await fetch("/api/users", {
headers: { Authorization: `Bearer ${token}` },
});
expect(response.ok).toBe(true);
// Should not access tenant-b
const wrongResponse = await fetch("/api/users", {
headers: {
Authorization: `Bearer ${token}`,
"X-Tenant-Id": "tenant-b",
},
});
expect(wrongResponse.status).toBe(403);
});
});Deployment
Phased Rollout
Phase 1: Deploy to staging
- Test all functionality
- Run migration scripts
- Verify data integrity
Phase 2: Deploy to production (read-only)
- Deploy multi-tenancy code
- Keep in read-only mode
- Monitor for issues
Phase 3: Enable writes
- Switch to full multi-tenancy
- Monitor carefully
- Be ready to rollback
Phase 4: Clean up
- Remove old code
- Archive old databases (if migrated)
- Update documentation
Deployment Script
#!/bin/bash
set -e
echo "🚀 Starting multi-tenancy migration..."
# 1. Backup
echo "📦 Creating backup..."
./scripts/backup.sh
# 2. Deploy new code
echo "📤 Deploying code..."
pnpm run deploy
# 3. Run migrations
echo "🔄 Running migrations..."
pnpm run migrate
# 4. Verify
echo "✅ Verifying deployment..."
pnpm run verify
echo "✨ Migration complete!"Rollback Plan
If issues occur, be prepared to rollback:
Rollback Steps
- Stop serving traffic (if possible)
- Revert code deployment
- Restore database backup
- Verify functionality
- Resume traffic
Rollback Script
#!/bin/bash
set -e
echo "⏪ Rolling back multi-tenancy migration..."
# 1. Stop traffic (optional)
echo "🛑 Stopping traffic..."
# cloudflare wrangler pages deployment rollback
# 2. Restore backup
echo "📦 Restoring backup..."
./scripts/restore.sh backup-20231201.sql
# 3. Deploy previous version
echo "📤 Deploying previous version..."
git checkout previous-version
pnpm run deploy
# 4. Verify
echo "✅ Verifying rollback..."
pnpm run verify
echo "✨ Rollback complete!"Common Issues
Issue 1: Token Validation Failures
Symptom: Users can't access tenant resources
Solution:
- Verify
org_idclaim in tokens - Check organization exists on main tenant
- Ensure user is member of organization
// Debug token
const decoded = jwt.decode(token);
console.log("org_id:", decoded.org_id);
// Check organization
const org = await getOrganization(decoded.org_id);
console.log("Organization:", org);Issue 2: Data Access Issues
Symptom: Some data is inaccessible after migration
Solution:
- Verify data was migrated correctly
- Check tenant_id scoping
- Ensure database connections are correct
// Verify data counts
const sourceCount = await sourceDb.query(
"SELECT COUNT(*) FROM users WHERE tenant_id = ?",
[tenantId],
);
const targetCount = await targetDb.query("SELECT COUNT(*) FROM users");
console.log("Source:", sourceCount, "Target:", targetCount);Issue 3: Performance Degradation
Symptom: Slower response times after migration
Solution:
- Add indexes on tenant_id columns
- Implement connection pooling
- Cache subdomain resolutions
- Use read replicas
// Add indexes
await db.exec(`
CREATE INDEX idx_users_tenant_id ON users(tenant_id);
CREATE INDEX idx_applications_tenant_id ON applications(tenant_id);
`);Best Practices
1. Test Thoroughly
Test the migration extensively before production:
// Test script
async function runMigrationTests() {
await testTenantIsolation();
await testAccessControl();
await testDataMigration();
await testPerformance();
}2. Monitor Closely
Set up monitoring during and after migration:
// Monitor key metrics
monitor.track({
metric: "tenant_requests",
value: requestCount,
tags: { tenant_id: tenantId },
});3. Document Changes
Update documentation for your team:
- API changes
- Authentication flow changes
- New environment variables
- Configuration options
4. Communicate
Inform stakeholders about:
- Migration timeline
- Expected downtime (if any)
- Changes to access patterns
- New features available
Next Steps
- Architecture - Understanding the multi-tenancy model
- Database Isolation - Learn about per-tenant databases
- API Reference - Complete API documentation