Multi-Tenant Auth Architecture: A Developer’s Guide
Last updated: March 2026
Introduction
Every SaaS application eventually faces the multi-tenancy question: how do you authenticate users from different organizations while keeping their data isolated? The answer depends on your scale, compliance requirements, and how much tenant customization you need.
Keycloak provides two primary patterns for multi-tenant authentication: realm-per-tenant and shared realm with Organizations. Each has trade-offs in isolation, operational complexity, and scalability. This guide covers both approaches in depth, along with tenant resolution strategies, data isolation patterns, and tenant-specific branding.
For a detailed walkthrough of Keycloak’s Organizations feature (introduced in Keycloak 26), see our guide on multi-tenancy using the Organizations feature. For the classic realm-based approach, see the ultimate guide to Keycloak multi-tenancy.
Multi-Tenancy Patterns Compared
Before diving into implementation, here is a comparison of the two main patterns:
| Aspect | Realm-per-Tenant | Shared Realm + Organizations |
|---|---|---|
| Data isolation | Strong (separate databases, caches) | Logical (same database, scoped queries) |
| Tenant customization | Full (separate flows, themes, IdPs) | Moderate (per-org settings) |
| Scalability | Limited (hundreds of realms max) | High (thousands of organizations) |
| Operational overhead | High (realm management automation) | Low (single realm to manage) |
| Compliance suitability | Strong (HIPAA, FedRAMP) | Good (SOC 2, GDPR) |
| Resource usage | High (per-realm caches) | Efficient (shared resources) |
| SSO across tenants | Complex | Built-in |
Pattern 1: Realm-per-Tenant
In this pattern, each tenant gets its own Keycloak realm. This provides the strongest isolation because each realm has its own:
- User database
- Client configurations
- Authentication flows
- Identity providers
- Theme/branding
- Session management
- Token signing keys
When to Use Realm-per-Tenant
- Regulated industries requiring strict data isolation (healthcare, finance)
- Tenants need completely different authentication flows
- Each tenant brings their own identity provider (SAML, OIDC)
- Fewer than 500 tenants
Implementation
Automated Realm Provisioning
When a new tenant signs up, create a realm programmatically using the Keycloak Admin REST API:
import requests
from typing import Any
class TenantProvisioner:
"""Automates Keycloak realm creation for new tenants."""
def __init__(self, keycloak_url: str, admin_token: str):
self.base_url = f"{keycloak_url}/admin/realms"
self.headers = {
"Authorization": f"Bearer {admin_token}",
"Content-Type": "application/json",
}
def create_tenant_realm(
self, tenant_id: str, tenant_name: str,
admin_email: str,
) -> dict[str, Any]:
"""Create a fully configured realm for a new tenant."""
# 1. Create the realm
realm_config = {
"realm": f"tenant-{tenant_id}",
"displayName": tenant_name,
"enabled": True,
"registrationAllowed": False,
"loginWithEmailAllowed": True,
"duplicateEmailsAllowed": False,
"resetPasswordAllowed": True,
"editUsernameAllowed": False,
"sslRequired": "external",
# Session settings
"ssoSessionIdleTimeout": 1800, # 30 min
"ssoSessionMaxLifespan": 36000, # 10 hours
"accessTokenLifespan": 300, # 5 min
# Branding
"loginTheme": "skycloak",
"accountTheme": "skycloak",
# Security
"bruteForceProtected": True,
"permanentLockout": False,
"maxFailureWaitSeconds": 900,
"failureFactor": 5,
}
resp = requests.post(
self.base_url,
json=realm_config,
headers=self.headers,
)
resp.raise_for_status()
realm_name = f"tenant-{tenant_id}"
# 2. Create default client
self._create_client(realm_name, tenant_id)
# 3. Create default roles
self._create_roles(realm_name)
# 4. Create tenant admin user
self._create_admin_user(
realm_name, admin_email, tenant_name,
)
return {"realm": realm_name, "status": "created"}
def _create_client(
self, realm_name: str, tenant_id: str,
) -> None:
client_config = {
"clientId": f"{tenant_id}-app",
"name": "Tenant Application",
"enabled": True,
"publicClient": True,
"standardFlowEnabled": True,
"directAccessGrantsEnabled": False,
"redirectUris": [
f"https://{tenant_id}.app.example.com/*",
],
"webOrigins": [
f"https://{tenant_id}.app.example.com",
],
"defaultClientScopes": [
"openid", "profile", "email",
],
}
requests.post(
f"{self.base_url}/{realm_name}/clients",
json=client_config,
headers=self.headers,
).raise_for_status()
def _create_roles(self, realm_name: str) -> None:
for role in ["tenant-admin", "member", "viewer"]:
requests.post(
f"{self.base_url}/{realm_name}/roles",
json={"name": role},
headers=self.headers,
).raise_for_status()
def _create_admin_user(
self, realm_name: str, email: str, tenant_name: str,
) -> None:
user_config = {
"username": email,
"email": email,
"enabled": True,
"emailVerified": True,
"firstName": "Admin",
"lastName": tenant_name,
"requiredActions": ["UPDATE_PASSWORD"],
}
resp = requests.post(
f"{self.base_url}/{realm_name}/users",
json=user_config,
headers=self.headers,
)
resp.raise_for_status()
# Get user ID from Location header
user_id = resp.headers["Location"].split("/")[-1]
# Assign tenant-admin role
role_resp = requests.get(
f"{self.base_url}/{realm_name}/roles/tenant-admin",
headers=self.headers,
)
role = role_resp.json()
requests.post(
f"{self.base_url}/{realm_name}"
f"/users/{user_id}/role-mappings/realm",
json=[role],
headers=self.headers,
).raise_for_status()
Tenant Resolution Middleware
Your application needs to determine which Keycloak realm to use for each request. Common strategies:
# middleware/tenant_resolver.py
from fastapi import Request, HTTPException
class TenantResolver:
"""Resolves tenant from request context."""
async def resolve(self, request: Request) -> str:
"""
Try multiple resolution strategies in order.
Returns the Keycloak realm name for this tenant.
"""
tenant_id = (
self._from_subdomain(request)
or self._from_header(request)
or self._from_path(request)
)
if not tenant_id:
raise HTTPException(
status_code=400,
detail="Could not determine tenant",
)
return f"tenant-{tenant_id}"
def _from_subdomain(self, request: Request) -> str | None:
"""Extract tenant from subdomain.
acme.app.example.com -> acme
"""
host = request.headers.get("host", "")
parts = host.split(".")
if len(parts) >= 3:
return parts[0]
return None
def _from_header(self, request: Request) -> str | None:
"""Extract tenant from X-Tenant-ID header."""
return request.headers.get("x-tenant-id")
def _from_path(self, request: Request) -> str | None:
"""Extract tenant from URL path.
/api/tenants/acme/... -> acme
"""
path_parts = request.url.path.strip("/").split("/")
if len(path_parts) >= 3 and path_parts[1] == "tenants":
return path_parts[2]
return None
Scaling Challenges
Realm-per-tenant hits practical limits around 500-1000 realms. Each realm consumes memory for caches (Infinispan), and admin operations slow down. If you anticipate more tenants, consider the shared-realm approach.
For cluster configuration guidance, see our Keycloak cluster best practices.
Pattern 2: Shared Realm with Organizations
Keycloak 26 introduced the Organizations feature, which provides multi-tenancy within a single realm. Organizations are logical groupings of users with:
- Membership management
- Organization-specific identity providers
- Organization attributes
- Claims in tokens (via mappers)
When to Use Shared Realm
- Hundreds or thousands of tenants
- Users may belong to multiple organizations
- You want SSO across tenants
- Lighter compliance requirements (SOC 2, GDPR)
- Faster provisioning (no realm creation overhead)
Enabling Organizations
- In Keycloak admin, go to Realm Settings > General.
- Enable Organizations.
- Navigate to the new Organizations menu item.
Creating Organizations via API
import requests
class OrganizationManager:
"""Manages Keycloak Organizations for multi-tenancy."""
def __init__(self, keycloak_url: str, realm: str,
admin_token: str):
self.base_url = (
f"{keycloak_url}/admin/realms/{realm}/organizations"
)
self.realm_url = f"{keycloak_url}/admin/realms/{realm}"
self.headers = {
"Authorization": f"Bearer {admin_token}",
"Content-Type": "application/json",
}
def create_organization(
self, name: str, display_name: str,
domains: list[str],
attributes: dict | None = None,
) -> dict:
"""Create a new organization."""
org_config = {
"name": name,
"alias": name,
"enabled": True,
"attributes": attributes or {},
"domains": [
{"name": d, "verified": True}
for d in domains
],
}
resp = requests.post(
self.base_url,
json=org_config,
headers=self.headers,
)
resp.raise_for_status()
org_id = resp.headers["Location"].split("/")[-1]
return {"id": org_id, "name": name}
def add_member(
self, org_id: str, user_id: str,
) -> None:
"""Add an existing user to an organization."""
requests.post(
f"{self.base_url}/{org_id}/members",
json=user_id,
headers=self.headers,
).raise_for_status()
def list_members(self, org_id: str) -> list[dict]:
"""List organization members."""
resp = requests.get(
f"{self.base_url}/{org_id}/members",
headers=self.headers,
)
resp.raise_for_status()
return resp.json()
def configure_idp(
self, org_id: str, idp_alias: str,
) -> None:
"""Link an identity provider to an organization.
Users authenticating via this IdP are auto-added
to the organization.
"""
requests.post(
f"{self.base_url}/{org_id}"
f"/identity-providers",
json=idp_alias,
headers=self.headers,
).raise_for_status()
Token Claims for Organizations
Configure a protocol mapper to include organization information in tokens:
- Go to Client Scopes > Create > Name:
organization. - Add a mapper: Organization Membership.
- This adds the
organizationsclaim to tokens.
The resulting token includes:
{
"sub": "user-uuid",
"organizations": {
"acme-corp": {
"id": "org-uuid",
"roles": ["admin", "member"]
}
}
}
Inspect these claims with the JWT Token Analyzer.
Tenant-Aware Middleware (Shared Realm)
With organizations, tenant resolution happens after authentication by inspecting the token claims:
from fastapi import Depends, HTTPException, Request
from typing import Any
async def get_current_tenant(
request: Request,
token_claims: dict = Depends(verify_keycloak_token),
) -> dict[str, Any]:
"""
Extract and validate the tenant context from
organization claims in the token.
"""
organizations = token_claims.get("organizations", {})
if not organizations:
raise HTTPException(
status_code=403,
detail="User is not a member of any organization",
)
# Determine active tenant from request context
requested_tenant = (
request.headers.get("x-tenant-id")
or _tenant_from_subdomain(request)
)
if requested_tenant and requested_tenant in organizations:
org_data = organizations[requested_tenant]
return {
"tenant_id": requested_tenant,
"org_id": org_data["id"],
"roles": org_data.get("roles", []),
}
# Default to first organization
first_org = next(iter(organizations.items()))
return {
"tenant_id": first_org[0],
"org_id": first_org[1]["id"],
"roles": first_org[1].get("roles", []),
}
def _tenant_from_subdomain(request: Request) -> str | None:
host = request.headers.get("host", "")
parts = host.split(".")
return parts[0] if len(parts) >= 3 else None
Tenant Resolution Strategies
Regardless of the Keycloak pattern you choose, your application needs a consistent way to identify the tenant. Here are the common approaches:
Subdomain-Based
acme.app.example.com maps to tenant acme.
- Pros: Clean URLs, works well with DNS wildcards, tenant-specific SSL certificates
- Cons: Requires wildcard DNS and SSL, harder to set up locally
Header-Based
The client sends X-Tenant-ID: acme with each request.
- Pros: Simple, works with any domain setup
- Cons: Requires client cooperation, can be spoofed if not validated against the token
Path-Based
app.example.com/tenants/acme/api/... routes to tenant acme.
- Pros: Simple routing, single domain
- Cons: Cluttered URLs, harder to migrate later
Token-Based (Recommended with Organizations)
The tenant is determined from the organizations claim in the Keycloak token.
- Pros: Cryptographically verified, cannot be spoofed, works with any URL scheme
- Cons: Requires token inspection on every request
Data Isolation
Authentication is only half the multi-tenancy challenge. You also need data isolation at the application level.
Row-Level Security (PostgreSQL)
-- Enable RLS on the projects table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Policy: users can only see their tenant's data
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant'));
-- In your application middleware, set the tenant before queries
SET app.current_tenant = 'acme-corp';
Application-Level Filtering
from sqlalchemy import event
from sqlalchemy.orm import Session
class TenantFilterMiddleware:
"""Automatically filter all queries by tenant."""
@staticmethod
def apply_tenant_filter(
session: Session, tenant_id: str,
) -> None:
@event.listens_for(session, "do_orm_execute")
def _filter(execute_state):
if execute_state.is_select:
execute_state.statement = (
execute_state.statement.filter_by(
tenant_id=tenant_id,
)
)
Tenant-Specific Branding
Keycloak supports per-realm themes (realm-per-tenant) and per-organization branding hints. On Skycloak, custom branding is available out of the box.
Realm-per-Tenant Branding
Each realm can have its own login theme:
# Set a custom theme for a tenant's realm
def set_tenant_theme(
keycloak_url: str, realm: str,
admin_token: str, theme_config: dict,
) -> None:
requests.put(
f"{keycloak_url}/admin/realms/{realm}",
json={
"loginTheme": theme_config.get(
"login_theme", "keycloak",
),
"accountTheme": theme_config.get(
"account_theme", "keycloak",
),
"attributes": {
"brandColor": theme_config.get(
"primary_color", "#3b82f6",
),
"logoUrl": theme_config.get("logo_url", ""),
},
},
headers={
"Authorization": f"Bearer {admin_token}",
"Content-Type": "application/json",
},
).raise_for_status()
Organization-Based Branding
With Organizations, use the organization’s domain to detect branding at the login page. Keycloak can redirect to the organization-specific IdP automatically when the user enters their email address:
- Configure domains on the organization (e.g.,
acme.com). - Enable “Redirect to organization identity provider” on the authentication flow.
- When a user types
[email protected], Keycloak detects the domain and redirects to Acme’s IdP.
Monitoring Multi-Tenant Environments
Monitoring is critical for multi-tenant systems. Key metrics to track:
- Per-tenant authentication rates: Detect anomalies or tenant-specific issues
- Failed login rates by tenant: Identify brute force attempts
- Active sessions per tenant: Capacity planning
- Token issuance latency: Performance per realm/organization
Keycloak’s audit logging captures all authentication events with realm/organization context. On Skycloak, the insights dashboard provides pre-built multi-tenant analytics, and session management lets you monitor active sessions across tenants.
For SCIM-based user provisioning across tenants, see our SCIM feature and the SCIM Endpoint Tester tool.
Choosing the Right Pattern
Use this decision framework:
Choose realm-per-tenant when:
- You have fewer than 500 tenants
- Regulatory compliance requires physical data separation
- Each tenant needs completely custom authentication flows
- Tenants require separate identity providers with complex configurations
Choose shared realm with Organizations when:
- You expect hundreds or thousands of tenants
- Users may belong to multiple organizations
- You want fast tenant provisioning (no realm creation overhead)
- SSO across organizations is a requirement
- You prioritize operational simplicity
Consider a hybrid approach when:
- Most tenants fit the shared-realm model
- A few large enterprise tenants require dedicated realms for compliance
- You want the flexibility to migrate tenants between models
To calculate the cost implications of each approach, use our ROI Calculator.
Conclusion
Multi-tenant authentication architecture is ultimately about trade-offs between isolation, scalability, and operational complexity. Keycloak supports both ends of the spectrum: dedicated realms for maximum isolation and Organizations for scalable logical separation.
Key takeaways:
- Realm-per-tenant: Strongest isolation, but operationally heavy and limited to hundreds of tenants
- Organizations: Lightweight, scalable, and built for SaaS, but requires application-level data isolation
- Tenant resolution: Token-based resolution is the most secure; combine with subdomain or header for UX
- Data isolation: Always enforce at the database level, regardless of the authentication pattern
- Branding: Both patterns support per-tenant customization through themes or domain detection
For the official Keycloak Organizations documentation, see the Keycloak Server Administration Guide. For multi-tenancy configuration details, see our companion guides on Keycloak multi-tenancy and Organizations.
Building a multi-tenant SaaS application? Try Skycloak free for managed Keycloak with Organizations support, custom branding, and enterprise SLA.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.