Multi-Tenant Auth Architecture: A Developer’s Guide

Guilliano Molaire Guilliano Molaire Updated May 31, 2026 10 min read

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

  1. In Keycloak admin, go to Realm Settings > General.
  2. Enable Organizations.
  3. 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:

  1. Go to Client Scopes > Create > Name: organization.
  2. Add a mapper: Organization Membership.
  3. This adds the organizations claim 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:

  1. Configure domains on the organization (e.g., acme.com).
  2. Enable “Redirect to organization identity provider” on the authentication flow.
  3. 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.

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman