Multi-Tenant SSO
B2B SaaS reality: each customer has their own IdP (Okta, Azure AD, Google Workspace, Auth0). Your app needs to route the user to the right IdP based on… something. This is “multi-tenant SSO” — one app, many IdPs.
The shape of the problem
User → app.example.com → "which IdP?"
↓
┌───────────────┼───────────────┐
↓ ↓ ↓
Acme's Okta Globex's Azure Initech's Auth0
Three things to design:
- IdP discovery — figure out which IdP this user belongs to.
- Per-tenant configuration — store each customer’s IdP settings.
- Trust boundaries — one customer’s user must NEVER end up in another customer’s tenant.
IdP discovery — Home Realm Discovery (HRD)
Three patterns:
| Pattern | How |
|---|---|
| Email domain | user enters email → match domain to tenant (alice@acme.com → Acme) |
| Subdomain / URL | acme.example.com → Acme’s IdP |
| Workspace selector | user types organization slug (“acme”) at login |
The most common combination: subdomain-per-tenant + email-domain fallback.
Email-domain HRD
@app.get("/login")
def login(email: str):
domain = email.split("@", 1)[1].lower()
tenant = Tenant.query.filter_by(email_domain=domain).first()
if not tenant:
return show_password_login(email) # fallback
return redirect_to_tenant_idp(tenant)
UX: a single login page with email input; backend routes after entry.
Caveats:
- Some customers have multiple domains (
acme.com,acme.io). - Some have non-corporate emails for users (consultants).
- Personal email domains (
gmail.com) clearly aren’t tenant-specific.
Subdomain-per-tenant
acme.example.com → Acme's IdP, Acme's data
globex.example.com → Globex's IdP, Globex's data
Pros:
- Clear visual cue.
- Each tenant gets a memorable URL.
- Easy to enforce tenant boundary in middleware.
Cons:
- DNS / SSL setup per tenant (wildcard cert mitigates).
- Cookie scoping needs care (don’t share session cookies across subdomains).
For a true subdomain-per-tenant, set cookie scope explicitly to NOT span subdomains:
response.set_cookie("session", sid, domain=None) # default = current subdomain only
Workspace selector
1. User visits app.example.com/login
2. Enter "acme" → redirect to acme.app.example.com
3. From there, normal SSO flow
Pros: works for any domain structure. Cons: extra step; users might not remember their workspace.
Per-tenant IdP configuration
Each tenant configures:
class TenantSSO(Base):
tenant_id: Mapped[int] = mapped_column(primary_key=True)
protocol: Mapped[str] # "oidc" or "saml"
enabled: Mapped[bool] = mapped_column(default=True)
# OIDC fields
oidc_issuer: Mapped[str | None]
oidc_client_id: Mapped[str | None]
oidc_client_secret: Mapped[str | None] # encrypted
# SAML fields
saml_entity_id: Mapped[str | None]
saml_sso_url: Mapped[str | None]
saml_cert: Mapped[str | None] # IdP's signing cert (PEM)
saml_metadata_xml: Mapped[str | None] # raw metadata, parsed at runtime
# Common
default_role: Mapped[str | None]
group_role_mappings: Mapped[dict] = mapped_column(JSONB)
Admin UI lets the customer paste their IdP metadata XML or enter OIDC discovery URL. Validate the config:
- For OIDC: fetch the discovery doc; check
issuermatches what they configured. - For SAML: parse metadata XML; check signing cert is well-formed.
Tenant binding in the SSO flow
Critical: every SSO interaction must be tied to the tenant. Risks if you don’t:
- Cross-tenant data leak (Acme user logs in, ends up in Globex’s tenant).
- Auth bypass (attacker crafts an assertion that’s valid for some tenant; if your app doesn’t pin the assertion to a specific tenant, takeover possible).
OIDC binding via state
def start_login(tenant: Tenant) -> str:
state = secrets.token_urlsafe(32)
redis.setex(f"sso_state:{state}", 600, json.dumps({
"tenant_id": tenant.id,
"issuer": tenant.oidc_issuer,
"nonce": secrets.token_urlsafe(32),
}))
return build_authorize_url(tenant, state)
def handle_callback(state: str, code: str):
data = redis.get(f"sso_state:{state}")
if not data:
raise InvalidStateError
flow = json.loads(data)
tenant = Tenant.query.get(flow["tenant_id"])
# exchange code at THIS tenant's IdP
tokens = oauth_client.exchange(code, tenant)
claims = validate_id_token(tokens["id_token"], expected_issuer=flow["issuer"], expected_nonce=flow["nonce"])
# CRITICAL: user goes into THIS tenant
user = upsert_user(claims, tenant=tenant)
The state is the binding. Each authorize request creates a new state; the callback uses it to know which tenant to terminate the flow in.
SAML binding via SP entity ID per tenant
For SAML, give each tenant its own SP entity ID and ACS URL:
acme.app.example.com/saml/acs → SP entity ID = acme:app
globex.app.example.com/saml/acs → SP entity ID = globex:app
The IdP’s metadata configures the SP entity ID it sends assertions to. An assertion intended for Acme’s SP won’t validate against Globex’s audience.
RelayState and tenant
For SAML SP-initiated flows, RelayState carries the tenant context:
relay_state = base64.urlsafe_b64encode(json.dumps({
"tenant_id": tenant.id,
"next": original_url,
}).encode()).decode()
On ACS callback, parse RelayState, route to the tenant’s namespace. Validate the assertion’s audience matches that tenant’s SP entity ID.
Per-tenant cert / key rotation
Each tenant’s IdP has its own signing cert that rotates (usually annually). Your app needs to handle:
- Cert near expiry — alert the customer.
- Cert expired — SSO breaks; need fast turnaround.
- Multiple valid certs (overlap during rotation).
Best practice: re-fetch metadata XML periodically (every 24 hr) from the IdP if it publishes a metadata URL. Customers who paste raw cert/URL get a static config; they must update on rotation.
For OIDC: JWKS at <issuer>/.well-known/jwks.json includes all current keys; rotation is automatic if you re-fetch.
Tenant isolation in the database
Two patterns:
| Pattern | Schema |
|---|---|
| Shared tables + tenant_id column | every row has tenant_id; queries scope on it |
| Schema-per-tenant | each tenant has its own Postgres schema |
| Database-per-tenant | each tenant has its own DB |
For most B2B SaaS: shared tables + tenant_id column.
Enforce in code:
@event.listens_for(Session, "do_orm_execute")
def filter_by_tenant(execute_state):
if execute_state.is_select:
execute_state.statement = execute_state.statement.options(
with_loader_criteria(
TenantScoped,
lambda cls: cls.tenant_id == current_tenant_id(),
include_aliases=True,
)
)
Defense in depth: combine app-level scoping with Postgres Row-Level Security (CREATE POLICY ... USING (tenant_id = current_setting('app.tenant_id')::int)).
Login UI design
[ Welcome to YourApp ]
Email: [____________]
[ Continue ]
─────── or ────────
[ Continue with Google ]
Continue performs HRD:
- Email’s domain matches a tenant → redirect to that tenant’s IdP.
- Email is personal (gmail, yahoo) → fallback (password, social login).
- Email doesn’t match → “no account; contact your administrator.”
For subdomain-per-tenant: skip email; the URL is the discovery.
For workspace selector: ask for workspace slug, redirect.
“SSO required for this domain”
Once a tenant enables SSO, your app should refuse non-SSO logins for users of that tenant’s email domain — otherwise users could create local password accounts bypassing the IdP (and the IdP’s MFA/policies).
def login(email: str, password: str | None = None):
domain = email.split("@")[1]
tenant = Tenant.query.filter_by(email_domain=domain).first()
if tenant and tenant.sso_required:
return redirect_to_idp(tenant)
if password:
return password_login(email, password)
return show_options(email)
Auto-discovery libraries
Don’t roll your own metadata parsing. Use:
| Library | Purpose |
|---|---|
authlib |
OIDC + OAuth |
python3-saml |
SAML SP |
pysaml2 |
SAML SP, more featureful |
werkzeug-sso / etc. |
smaller |
For commercial enterprise SSO: services like WorkOS, Cognito, Auth0, Frontegg, Stytch handle multi-tenant SSO as a managed product. Pay them, save weeks of dev. See 11_python_libraries.md.
Common pitfalls
- No tenant binding on the callback — accept any valid assertion regardless of tenant; user ends up in the wrong tenant.
- Per-tenant config but shared
statenamespace — collision possible; use tenant-scoped state keys. - One client_id across all OIDC tenants — each tenant’s IdP needs its own client_id (and the client_id should be associated with that tenant in your config).
- Wildcard cookie domain — sessions leak across tenants.
- No tenant_id check on queries — assume “logged in” means “authorized,” forget that the user belongs to a specific tenant. Always scope DB queries.
Common interview confusions
- “Multi-tenant SSO means one IdP for everyone.” — opposite. Each tenant has their OWN IdP. The challenge is routing users to the right one and enforcing trust boundaries.
- “Subdomain-per-tenant means subdomain sharing for the cookie.” — DON’T share cookies across subdomains; that’s how tenants leak into each other. Scope cookies to the specific subdomain.
- “Email domain HRD is unambiguous.” — multiple domains per tenant, employees with personal-domain emails, contractors, and acquisitions all break the assumption.
Interview angle
- “How do you route a user to the right IdP in a multi-tenant SaaS?” — Home Realm Discovery via email domain (alice@acme.com → Acme), subdomain (acme.app.com → Acme), or workspace selector. Email domain is most common; subdomain is cleanest.
- “How do you store per-tenant SSO config?” — table per tenant with protocol type, OIDC issuer/client_id/secret or SAML entity_id/ACS/cert/metadata, plus group mappings and SSO-required flag. Client secrets encrypted at rest.
- “How do you prevent cross-tenant leak during the SSO callback?” — bind every authorize request to a tenant via
state(OIDC) or RelayState + per-tenant SP entity ID (SAML). On callback, look up tenant from state; validate assertion’s audience matches that tenant. - “Per-tenant cert rotation — how do you handle it?” — for OIDC: re-fetch JWKS (cached, ~daily). For SAML: re-fetch metadata URL if customer provided one; otherwise customers must update manually. Alert before expiry.
- “What’s ‘SSO required for this domain’?” — once a tenant enables SSO, refuse non-SSO logins for users in that domain. Prevents bypass via password account creation.
- “When would you buy multi-tenant SSO instead of build?” — when the customer list is growing and you’d otherwise spend ongoing time on per-IdP debugging. WorkOS, Auth0, Cognito are designed for this; cost is real but so is building/maintaining.