Skip to content

Multi Tenant Support#2392

Open
fadlikadn wants to merge 5 commits intoteamhanko:mainfrom
fadlikadn:multi-tenant
Open

Multi Tenant Support#2392
fadlikadn wants to merge 5 commits intoteamhanko:mainfrom
fadlikadn:multi-tenant

Conversation

@fadlikadn
Copy link
Copy Markdown
Contributor

@fadlikadn fadlikadn commented Jan 22, 2026

Description

This PR adds comprehensive multi-tenant support to Hanko, enabling the same email/username to be registered across multiple tenants with independent authentication credentials (passwords, passkeys, MFA). This architecture give flexibility for teams to handle tenant management and fit with various needs. This implementation only limited to handle tenant ID since I believe we can handle role and other areas in separated system (out of Hanko scope) based on needs.

Key Features:

  • Tenant isolation: Users with the same email in different tenants are treated as completely separate accounts
  • Independent credentials: Each tenant's user has their own password, WebAuthn credentials, OTP secrets, and sessions
  • HTTP header-based tenant identification (X-Tenant-ID)
  • Automatic tenant provisioning when a new tenant ID is provided
  • Full backward compatibility: When disabled (default), Hanko operates exactly as before
  • Admin API for tenant management (CRUD operations)
  • JWT tokens include tenant_id claim for downstream services

Use Case:
This enables SaaS platforms to use a single Hanko instance for multiple customer organizations, where:

  • user@example.com in Tenant A is a different account than user@example.com in Tenant B
  • Each has their own password, 2FA setup, passkeys, and session
  • Tenants are fully isolated from each other

Implementation

Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│                        HTTP Request                              │
│                   X-Tenant-ID: <uuid>                           │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Tenant Middleware                             │
│  1. Extract tenant ID from header                               │
│  2. Auto-provision tenant if not exists (when enabled)          │
│  3. Validate tenant is enabled                                  │
│  4. Set tenant context for downstream handlers                  │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Flow API Handlers                             │
│  - Registration: Uses tenant-scoped email/username lookup       │
│  - Login: Authenticates within tenant scope                     │
│  - All entities created with tenant_id                          │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Database Layer                                │
│  - Partial unique indexes for tenant-scoped uniqueness          │
│  - Foreign keys with CASCADE delete                             │
│  - Backward compatible: NULL tenant_id for global users         │
└─────────────────────────────────────────────────────────────────┘

Database Schema Changes

New Table: tenants

CREATE TABLE tenants (
    id UUID PRIMARY KEY,
    name VARCHAR NOT NULL,
    slug VARCHAR NOT NULL UNIQUE,
    config TEXT,
    enabled BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

Added tenant_id Column To:

  • users
  • emails
  • usernames
  • identities
  • webauthn_credentials
  • otp_secrets
  • password_credentials
  • sessions
  • audit_logs
  • flows

Uniqueness Strategy

Uses PostgreSQL partial unique indexes to allow:

  • Same email/username across different tenants
  • Global uniqueness when tenant_id IS NULL (backward compatibility)
-- Tenant-scoped uniqueness
CREATE UNIQUE INDEX emails_address_tenant_idx ON emails (address, tenant_id) WHERE tenant_id IS NOT NULL;
-- Global uniqueness (backward compatibility)
CREATE UNIQUE INDEX emails_address_global_idx ON emails (address) WHERE tenant_id IS NULL;

New Files

File Purpose
backend/config/config_multi_tenant.go Configuration struct for multi-tenant settings
backend/persistence/models/tenant.go Tenant model
backend/persistence/tenant_persister.go Tenant CRUD operations
backend/middleware/tenant.go HTTP middleware for tenant resolution
backend/handler/tenant_admin.go Admin API handlers for tenant management
backend/dto/admin/tenant.go Tenant DTOs
backend/persistence/migrations/20260120000001_create_tenants.*.fizz Create tenants table
backend/persistence/migrations/20260120000002_add_tenant_id.*.fizz Add tenant_id columns
backend/persistence/migrations/20260120000003_change_unique_constraints.*.fizz Update uniqueness constraints

Modified Files

Configuration

  • backend/config/config.go - Added MultiTenant field
  • backend/config/config_default.go - Added default multi-tenant config
  • backend/json_schema/hanko.config.json - Regenerated to include multi-tenant schema

Models (Added TenantID field)

  • backend/persistence/models/user.go
  • backend/persistence/models/email.go
  • backend/persistence/models/username.go
  • backend/persistence/models/identity.go
  • backend/persistence/models/webauthn_credential.go
  • backend/persistence/models/otp_secret.go
  • backend/persistence/models/password_credential.go
  • backend/persistence/models/session.go
  • backend/persistence/models/audit_log.go
  • backend/persistence/models/flow.go

Persisters (Added tenant-aware methods)

  • backend/persistence/persister.go - Added GetTenantPersister() interface
  • backend/persistence/email_persister.go - Added FindByAddressAndTenant()
  • backend/persistence/username_persister.go - Added GetByNameAndTenant()
  • backend/persistence/user_persister.go - Added GetByEmailAddressAndTenant(), GetByUsernameAndTenant()

Flow API (Tenant-aware authentication)

  • backend/flow_api/flow/shared/flow.go - Added TenantID and Tenant to Dependencies
  • backend/flow_api/handler.go - Pass tenant context to flow dependencies
  • backend/flow_api/flow/registration/action_register_login_identifier.go - Tenant-scoped email/username lookup
  • backend/flow_api/flow/registration/hook_create_user.go - Set tenant_id on created entities
  • backend/flow_api/flow/credential_usage/action_continue_with_login_identifier.go - Tenant-scoped login
  • backend/flow_api/flow/credential_usage/action_password_login.go - Tenant-scoped password verification

Session & JWT

  • backend/session/session.go - Added tenant_id claim to JWT
  • backend/dto/user.go - Added TenantID to UserJWT struct

Webhooks

  • backend/webhooks/events/events.go - Added Tenant, TenantCreate, TenantUpdate, TenantDelete events

Routers

  • backend/handler/public_router.go - Applied tenant middleware
  • backend/handler/admin_router.go - Added tenant management routes

Configuration Options

multi_tenant:
  enabled: false              # Enable multi-tenant mode (default: false)
  tenant_header: "X-Tenant-ID" # HTTP header for tenant ID
  allow_global_users: true    # Allow users without tenant (backward compatible)
  auto_provision: true        # Auto-create tenant if ID doesn't exist

Admin API Endpoints

Method Endpoint Description
GET /tenants List all tenants (paginated)
POST /tenants Create new tenant
GET /tenants/:id Get tenant by ID
PUT /tenants/:id Update tenant
DELETE /tenants/:id Delete tenant (cascades to all tenant data)

Tradeoffs & Design Decisions

  1. Nullable tenant_id: Allows backward compatibility with existing single-tenant deployments. Global users (tenant_id = NULL) continue to work.

  2. Partial Unique Indexes: Used PostgreSQL-specific partial indexes for tenant-scoped uniqueness. This is the most efficient approach but ties us to PostgreSQL (and compatible databases like CockroachDB).

  3. Auto-Provisioning: Enabled by default for ease of use in development. Production deployments may want to disable this and manage tenants explicitly via Admin API.

  4. CASCADE Delete: When a tenant is deleted, all associated users, credentials, and sessions are deleted. This is intentional for clean tenant removal.

  5. Header-Based Tenant ID: Using HTTP header (vs URL path or query param) keeps the API surface clean and allows the same endpoints for all tenants.

Tests

Manual Testing

Prerequisites

# Start with fresh database
docker compose -f deploy/docker-compose/quickstart.yaml -p "hanko-quickstart" down -v
docker compose -f deploy/docker-compose/quickstart.yaml -p "hanko-quickstart" up --build

Test 1: Backward Compatibility (Multi-tenant Disabled)

# With default config (multi_tenant.enabled: false), existing behavior works
curl -X POST http://localhost:8000/registration \
  -H "Content-Type: application/json" \
  -d '{}'
# Should return flow state without any tenant context

Test 2: Enable Multi-tenant and Auto-provision

# Add to config.yaml
multi_tenant:
  enabled: true
  auto_provision: true
TENANT_A="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
TENANT_B="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"

# Register user in Tenant A
curl -X POST http://localhost:8000/registration \
  -H "Content-Type: application/json" \
  -H "X-Tenant-ID: $TENANT_A" \
  -d '{}'
# Complete flow with email: user@example.com

# Register SAME email in Tenant B (should succeed!)
curl -X POST http://localhost:8000/registration \
  -H "Content-Type: application/json" \
  -H "X-Tenant-ID: $TENANT_B" \
  -d '{}'
# Complete flow with email: user@example.com

Test 3: Verify Tenant Admin API

# List tenants (both should exist from auto-provisioning)
curl -X GET http://localhost:8001/tenants | jq .

# Create tenant manually
curl -X POST http://localhost:8001/tenants \
  -H "Content-Type: application/json" \
  -d '{"name": "Acme Corp", "slug": "acme"}' | jq .

# Update tenant
curl -X PUT http://localhost:8001/tenants/$TENANT_A \
  -H "Content-Type: application/json" \
  -d '{"name": "Updated Name", "enabled": false}' | jq .

# Access disabled tenant should fail
curl -X POST http://localhost:8000/registration \
  -H "X-Tenant-ID: $TENANT_A" \
  -H "Content-Type: application/json" \
  -d '{}'
# Should return 403 Forbidden

Test 4: Verify JWT Contains tenant_id

After successful login with tenant header, decode the JWT:

# JWT payload should include:
{
  "sub": "<user-uuid>",
  "tenant_id": "<tenant-uuid>",
  "email": "user@example.com",
  ...
}

Test 5: Database Verification

docker exec -it hanko-quickstart-postgresd-1 psql -U hanko -d hanko

-- Check tenants were created
SELECT id, name, slug, enabled FROM tenants;

-- Check users have tenant_id
SELECT id, tenant_id, created_at FROM users;

-- Check same email exists in different tenants
SELECT e.address, e.tenant_id, t.name as tenant_name
FROM emails e
LEFT JOIN tenants t ON e.tenant_id = t.id
WHERE e.address = 'user@example.com';

Additional Context

Configuration Example

# Full multi-tenant configuration
multi_tenant:
  enabled: true                    # Enable multi-tenant mode
  tenant_header: "X-Tenant-ID"     # HTTP header for tenant ID (UUID)
  allow_global_users: true         # Allow requests without tenant header
  auto_provision: true             # Auto-create tenants on first request

# Strict production mode (no auto-provisioning, tenant required)
multi_tenant:
  enabled: true
  tenant_header: "X-Tenant-ID"
  allow_global_users: false        # Require tenant for all requests
  auto_provision: false            # Must create tenants via Admin API

JWT Token with Tenant

When multi-tenant is enabled and a user authenticates within a tenant context, the JWT includes:

{
  "sub": "550e8400-e29b-41d4-a716-446655440000",
  "tenant_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
  "email": "user@example.com",
  "iat": 1705708800,
  "exp": 1705752000
}

Webhook Events

New webhook events for tenant lifecycle:

  • tenant.create - Fired when a tenant is created (including auto-provisioning)
  • tenant.update - Fired when a tenant is updated
  • tenant.delete - Fired when a tenant is deleted

Breaking Changes

None. This is a fully backward-compatible change:

  • Multi-tenant mode is disabled by default
  • Existing deployments continue to work without configuration changes
  • Database migrations are additive (new table, new nullable columns)
  • Existing users remain as "global" users (tenant_id = NULL)

- Introduced multi-tenant configuration in `hanko.config.json` with options for enabling multi-tenant mode, specifying tenant headers, and auto-provisioning tenants.
- Implemented `Tenant` middleware to resolve tenant from HTTP headers and auto-provision tenants if enabled.
- Added `Tenant` model and persister for managing tenant data in the database.
- Updated existing models (User, Email, Username, etc.) to include `tenant_id` for tenant-scoped data.
- Enhanced user and email persisters to support tenant-specific queries.
- Created migration scripts for adding tenants table and modifying existing tables to include tenant_id.
- Updated webhook events to include tenant-related events (create, update, delete).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant