MCD lets you resolve the Auth0 domain per request while keeping a single ServerClient instance. This is useful when your application uses multiple custom domains configured on the same Auth0 tenant.
Important: MCD supports multiple custom domains on a single Auth0 tenant. It does not support connecting to multiple Auth0 tenants from a single application. Each custom domain must belong to the same Auth0 tenant. Using domains from different Auth0 tenants is not supported and will result in authentication failures.
Example:
https://brand-1.yourapp.com→ Custom domain:login.brand-1.comhttps://brand-2.yourapp.com→ Custom domain:login.brand-2.com
MCD is enabled by providing a domain resolver function instead of a static domain string.
See Security Best Practices for important guidance on configuring your resolver safely.
For applications with a single Auth0 domain:
from auth0_server_python import ServerClient
client = ServerClient(
domain="login.yourapp.com", # Static string
client_id="your_client_id",
client_secret="your_client_secret",
secret="your_encryption_secret"
)For MCD support, provide a domain resolver function that receives a DomainResolverContext:
from auth0_server_python import ServerClient
from auth0_server_python.auth_types import DomainResolverContext
# Map your app hostnames to Auth0 custom domains
DOMAIN_MAP = {
"brand-1.yourapp.com": "login.brand-1.com",
"brand-2.yourapp.com": "login.brand-2.com",
}
DEFAULT_DOMAIN = "login.yourapp.com"
async def domain_resolver(context: DomainResolverContext) -> str:
"""
Resolve Auth0 domain based on request hostname.
Args:
context: Contains request_url and request_headers
Returns:
Auth0 domain string (e.g., "login.brand-1.com")
"""
# Extract hostname from request headers
if not context.request_headers:
return DEFAULT_DOMAIN
host = context.request_headers.get('host', DEFAULT_DOMAIN)
host_without_port = host.split(':')[0]
# Look up Auth0 domain
return DOMAIN_MAP.get(host_without_port, DEFAULT_DOMAIN)
client = ServerClient(
domain=domain_resolver, # Callable function
client_id="your_client_id",
client_secret="your_client_secret",
secret="your_encryption_secret"
)The DomainResolverContext object provides request information to your resolver:
| Property | Type | Description |
|---|---|---|
request_url |
Optional[str] |
Full request URL (e.g., "https://brand-1.yourapp.com/auth/login") |
request_headers |
Optional[dict[str, str]] |
Request headers dictionary |
Common headers:
host: Request hostname (e.g., "brand-1.yourapp.com")x-forwarded-host: Original host when behind proxy/load balancer
Example usage:
async def domain_resolver(context: DomainResolverContext) -> str:
# Check if we have request headers
if not context.request_headers:
return DEFAULT_DOMAIN
# Use x-forwarded-host if behind proxy, otherwise use host
host = (context.request_headers.get('x-forwarded-host') or
context.request_headers.get('host', ''))
# Remove port number if present
hostname = host.split(':')[0].lower()
# Look up in mapping
return DOMAIN_MAP.get(hostname, DEFAULT_DOMAIN)In resolver mode, pass store_options to each SDK call so the resolver can inspect the
current request and select the correct domain. If store_options are omitted, the resolver
receives empty context (request_url=None, request_headers=None).
All public SDK methods that interact with sessions or Auth0 endpoints accept store_options.
Here is an example using get_user():
# In your route handler, pass the framework request via store_options
store_options = {"request": request, "response": response}
# The SDK calls your domain_resolver with a DomainResolverContext
# built from the request in store_options
user = await client.get_user(store_options=store_options)The same pattern applies to get_session(), get_access_token(), start_interactive_login(),
logout(), and all other session-aware methods.
In resolver mode, the SDK does not infer redirect_uri from the request. You must provide it
explicitly:
- Set a default
redirect_uriwhen constructingServerClient, or - Pass
redirect_uriinauthorization_paramsfor each login call.
Framework wrappers like auth0-fastapi handle this automatically by constructing the
redirect_uri from the incoming request's host and scheme.
Note: In resolver mode, MCD needs an ID token in the callback so the SDK can validate the
issclaim. Theopenidscope is required to receive an ID token. Ensureopenidis included in yourauthorization_params.scope.
Resolve domains from a database using async SQLAlchemy:
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy import text
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/mydb")
async def domain_resolver(context: DomainResolverContext) -> str:
host = context.request_headers.get("host", "").split(":")[0]
subdomain = host.split(".")[0]
async with AsyncSession(engine) as session:
result = await session.execute(
text("SELECT auth0_domain FROM tenants WHERE slug = :slug"),
{"slug": subdomain}
)
row = result.fetchone()
if row:
return row[0]
return DEFAULT_DOMAINAvoid hitting the database on every request by caching the domain map:
import time
_domain_cache = {}
_cache_ttl = 300 # 5 minutes
async def domain_resolver(context: DomainResolverContext) -> str:
host = context.request_headers.get("host", "").split(":")[0]
subdomain = host.split(".")[0]
now = time.time()
cached = _domain_cache.get(subdomain)
if cached and cached["expires_at"] > now:
return cached["domain"]
# Cache miss - fetch from database
async with AsyncSession(engine) as session:
result = await session.execute(
text("SELECT auth0_domain FROM tenants WHERE slug = :slug"),
{"slug": subdomain}
)
row = result.fetchone()
domain = row[0] if row else DEFAULT_DOMAIN
_domain_cache[subdomain] = {"domain": domain, "expires_at": now + _cache_ttl}
return domainUse Redis for shared domain configuration across multiple app instances:
import redis.asyncio as redis
redis_client = redis.Redis(host="localhost", port=6379, decode_responses=True)
async def domain_resolver(context: DomainResolverContext) -> str:
host = context.request_headers.get("host", "").split(":")[0]
subdomain = host.split(".")[0]
# Key format: "tenant:brand-1" -> "login.brand-1.com"
domain = await redis_client.get(f"tenant:{subdomain}")
if domain:
return domain
return DEFAULT_DOMAINStore all domain mappings in a single Redis hash:
async def domain_resolver(context: DomainResolverContext) -> str:
host = context.request_headers.get("host", "").split(":")[0]
subdomain = host.split(".")[0]
# All domains in one hash: HGET tenant_domains brand-1 -> "login.brand-1.com"
domain = await redis_client.hget("tenant_domains", subdomain)
if domain:
return domain
return DEFAULT_DOMAINResolve from URL path instead of hostname:
from urllib.parse import urlparse
async def domain_resolver(context: DomainResolverContext) -> str:
if context.request_url:
path = urlparse(context.request_url).path
# URL pattern: /brand/brand-1/auth/login
parts = path.strip("/").split("/")
if len(parts) >= 2 and parts[0] == "brand":
name = parts[1]
return DOMAIN_MAP.get(name, DEFAULT_DOMAIN)
return DEFAULT_DOMAINUse a custom header set by your API gateway or load balancer:
async def domain_resolver(context: DomainResolverContext) -> str:
headers = context.request_headers or {}
# API gateway sets X-Brand-Id header
brand_id = headers.get("x-brand-id")
if brand_id:
return DOMAIN_MAP.get(brand_id, DEFAULT_DOMAIN)
# Fallback to host header
host = headers.get("host", "").split(":")[0]
return DOMAIN_MAP.get(host, DEFAULT_DOMAIN)The domain resolver should return a valid Auth0 domain string. Invalid returns will raise DomainResolverError:
from auth0_server_python.error import DomainResolverError
async def domain_resolver(context: DomainResolverContext) -> str:
try:
domain = lookup_domain_from_db(context)
if not domain:
# Return default instead of None
return DEFAULT_DOMAIN
return domain # Must be a non-empty string
except Exception as e:
# Log error and return default
logger.error(f"Domain resolution failed: {e}")
return DEFAULT_DOMAINInvalid return values that raise DomainResolverError:
None- Empty string
"" - Non-string types (int, list, dict, etc.)
Exceptions raised by your resolver:
- Automatically wrapped in
DomainResolverError - Original exception accessible via
.original_error
In resolver mode, sessions are bound to the domain that created them. On each request, the SDK compares the session's stored domain against the current resolved domain. If the domains do not match:
get_user()andget_session()returnNone.get_access_token()raisesAccessTokenError(codeMISSING_SESSION_DOMAINorDOMAIN_MISMATCH).get_access_token_for_connection()raisesAccessTokenForConnectionError(same codes as above).start_link_user()andstart_unlink_user()raiseStartLinkUserError.- Token refresh uses the session's stored domain, not the current request domain.
All domain mismatch errors use the message: "Session domain does not match the current domain."
Note: If a login was started before the switch to resolver mode and completes after, the SDK falls back to the current resolved domain for token exchange. The resulting session will store the resolved domain and work normally going forward.
When moving from a static domain setup to resolver mode, existing sessions can continue to work if the resolver returns the same Auth0 domain that was used for those legacy sessions.
The SDK uses a three-tier fallback to determine the session's domain:
session.domain— new sessions created after MCD was enabled store this field.- Static domain — if a static
domainstring was configured, it is used as a fallback. - User's issuer claim — the hostname is extracted from the
issclaim in the user's ID token (e.g.,https://login.brand-1.com/yieldslogin.brand-1.com).
This means legacy sessions created before MCD support will still work as long as the resolver returns a domain that matches one of the fallback values. In most cases, the issuer claim already matches the Auth0 domain, so no re-authentication is needed.
If the resolver returns a different domain that does not match any tier, the SDK treats the session as belonging to another domain and the user will need to sign in again. This is intentional to keep sessions isolated per domain.
The SDK caches OIDC metadata and JWKS per domain in memory (LRU eviction, 600-second TTL, up to 100 domains). This avoids repeated network calls when serving multiple domains. The cache is shared across all requests to the same ServerClient instance.
Most applications can keep the defaults, but you may want to adjust in these cases:
- Increase
max_entriesif one process handles more than 100 distinct Auth0 domains during the TTL window. This is most common in MCD deployments that work with many custom domains. - Decrease
max_entriesif memory usage matters more than avoiding repeated discovery. - Increase TTL if the same domains are reused frequently and you want to reduce repeated discovery and JWKS fetches after cache entries expire.
- Decrease TTL if you want the SDK to pick up Auth0 metadata or signing key changes sooner.
Rule of thumb: set max_entries to cover the number of distinct Auth0 domains a single process is expected to use during the TTL window, with some headroom.
The domain resolver is a security-critical component. A misconfigured resolver can lead to authentication bypass on the relying party (RP) or expose the application to Server-Side Request Forgery (SSRF). The SDK trusts the resolved domain to fetch OIDC metadata and verification keys. It is the customer's responsibility to ensure the resolver cannot be influenced by untrusted input.
Single Tenant Limitation: The domain resolver is intended solely for multiple custom domains belonging to the same Auth0 tenant. It is not a supported mechanism for connecting multiple Auth0 tenants to a single application.
The SDK passes request headers to your domain resolver via DomainResolverContext. These headers come directly from the HTTP request and can be spoofed by an attacker (e.g., Host: evil.com or X-Forwarded-Host: evil.com).
The SDK uses the resolved domain to fetch OIDC metadata and JWKS. If an attacker can influence the resolved domain, they could point the SDK at an OIDC provider they control.
Always use a mapping or allowlist — never construct domains from raw header values:
# Safe: allowlist lookup — unknown hosts fall back to default
DOMAIN_MAP = {
"brand-1.yourapp.com": "login.brand-1.com",
"brand-2.yourapp.com": "login.brand-2.com",
}
async def domain_resolver(context: DomainResolverContext) -> str:
host = context.request_headers.get("host", "").split(":")[0]
return DOMAIN_MAP.get(host, DEFAULT_DOMAIN)# Risky: constructs domain from raw input — attacker can influence resolved domain
async def domain_resolver(context: DomainResolverContext) -> str:
host = context.request_headers.get("host", "").split(":")[0]
subdomain = host.split(".")[0]
return f"login.{subdomain}.com" # attacker sends Host: evil.yourapp.com → login.evil.comWhen using Multiple Custom Domains (MCD), your application must be deployed behind a secure reverse proxy (e.g., Cloudflare, Nginx, or AWS ALB). The proxy must be configured to sanitize and overwrite Host and X-Forwarded-Host headers before they reach your application.
Without a trusted proxy layer to validate these headers, an attacker can manipulate the domain resolution process. This can result in authentication bypass or Server-Side Request Forgery (SSRF).
If your application is directly exposed to the internet (not behind a reverse proxy), do not trust x-forwarded-host or x-forwarded-proto — any client can set these headers.
Only use forwarded headers when your application runs behind a trusted reverse proxy (nginx, AWS ALB, Cloudflare, etc.) that sets these headers and strips any client-provided values.
# Only trust x-forwarded-host if behind a trusted proxy
async def domain_resolver(context: DomainResolverContext) -> str:
headers = context.request_headers or {}
if BEHIND_TRUSTED_PROXY:
host = headers.get("x-forwarded-host") or headers.get("host", "")
else:
host = headers.get("host", "")
host = host.split(":")[0]
return DOMAIN_MAP.get(host, DEFAULT_DOMAIN)