Build an Enterprise SSO System Federating 500 SaaS Applications
security api-design reliability
System Design Deep Dive
Enterprise SSO Identity Federation
One login must satisfy 500 apps’ trust models, and revocation must land everywhere within seconds.
A hotel room key card is a strange little piece of identity engineering. Issued once at check-in, that single card opens the room door, the gym turnstile, the pool gate, and sometimes the elevator to the executive floor, and every one of those readers is a different vendor’s hardware, running different firmware, none of which the hotel manufactured. The card itself does not decide anything. Each reader independently decides whether to trust the signal encoded on it, and the hotel’s real engineering problem is making sure that when a guest checks out, every one of those independent readers stops trusting that card within moments, not by the next time housekeeping happens to notice.
An enterprise single sign-on platform is that same problem at a scale no hotel ever has to solve. One employee identity, established once, has to be trusted by 500 different SaaS applications, some speaking SAML 2.0, some speaking OIDC, each with its own idea of what a group membership attribute should be called and its own tolerance for clock skew. At a company running 250,000 employee identities across 500 connected applications, that means somewhere near 1.5 million federated logins a day, each one requiring a credential check, a risk decision, and a protocol-specific assertion, all before the employee’s page even finishes loading. And when HR disables an account, whether for a routine offboarding or an urgent security termination, that decision has to propagate to every one of those 500 independently-operated applications in seconds, because every second that a disabled account can still authenticate into an app is a second the employee retains access nobody intended them to have.
The naive approach, giving every app its own username and password against a shared user table, fails on contact with reality at this scale. Five hundred apps means 500 separate password reset flows, 500 places a credential can leak, and worst of all, 500 separate places someone has to remember to revoke access when an employee leaves. There is no way to guarantee revocation happened everywhere, because there is no single moment where “everywhere” is even defined. Federating identity through a central authority solves the discovery problem, but it introduces a much harder one: SAML and OIDC are fundamentally different protocols with different trust models, different token formats, and different failure semantics, and a single identity provider has to speak both fluently to 500 independently configured relying parties, none of which it controls the deployment schedule for.
The forces genuinely in tension are protocol heterogeneity, propagation latency, and blast radius. Every login has to feel instant to the employee, which argues for caching and minimizing round trips, but every account disable has to reach hundreds of downstream systems within a hard latency budget, which argues for synchronous, verified delivery instead of eventual consistency. A compromised signing key or a misconfigured trust relationship with even one app can cascade into unauthorized access across the whole federation, so the system has to bound the blast radius of any single failure without slowing down the other 499 apps. We need to solve for protocol translation between SAML and OIDC through a single federation gateway, a central identity provider that is the one place credentials and MFA are ever actually verified, a provisioning pipeline that can push lifecycle changes to hundreds of apps under a hard latency SLA, and a policy engine that can demand stronger proof of identity exactly when risk is elevated, not uniformly everywhere.
Requirements and Constraints
Functional Requirements
- Support SAML 2.0 federation (SP-initiated and IdP-initiated, redirect and POST bindings) and OIDC federation (Authorization Code flow with PKCE) for up to 500 registered relying parties
- Maintain a central identity store as the system of record for 250,000 employee and contractor identities, including group and role attributes consumed by downstream apps
- Enforce configurable MFA policy per app sensitivity tier, computed risk score, and network context, including step-up authentication for sensitive actions
- Issue and validate short-lived sessions with a per-app configurable session lifetime, idle timeout, and forced re-authentication window
- Provision users just-in-time on first federated login for apps configured for JIT, and pre-provision or update accounts via SCIM push for apps that require an account to exist before first login
- Propagate deprovisioning (disable, suspend, delete) events to every subscribed app via SCIM within a hard latency SLA, with retries and dead-letter alerting on failure
- Give administrators self-service app registration (SAML metadata upload or OIDC client registration), MFA policy configuration, and an access-review directory of who can reach what
- Record every authentication, consent, MFA challenge, and provisioning event in an immutable audit trail for compliance review
Non-Functional Requirements
- Login throughput: sustain 200 SSO logins/sec on average across the federation, with regional login-storm bursts up to 1,500 logins/sec at the start of business hours
- Auth decision latency: p99 under 150ms for the credential-check-to-assertion-issuance path, excluding the human time spent on an MFA challenge itself
- Availability: 99.99% for the authentication path, since all 500 downstream apps treat it as a single, unavoidable dependency for every login
- Deprovisioning propagation: p99 under 5 seconds from an account disable event to a
disabledacknowledgment from every subscribed app’s SCIM endpoint, with unresponsive apps flagged rather than silently allowed to lag indefinitely - Session validation throughput: sustain 20,000 token or session validations/sec in aggregate across all connected apps
- Durability: zero silent loss of provisioning or deprovisioning events; every event is retried until acknowledged or explicitly dead-lettered with a page
- Key rotation: SAML and OIDC signing keys rotate without downtime, with a documented overlap window so lagging apps have time to fetch new metadata or JWKS
Constraints and Assumptions
- We are not designing each connected app’s internal authorization or RBAC model beyond the group and role attributes passed at login; what an app does with those attributes is that app’s own concern
- We treat an upstream HR system (for example, Workday) as the ultimate source of truth for employee lifecycle events; this post covers propagating those events, not the HR system itself
- Password-based authentication is supported for legacy compatibility, but WebAuthn passkeys are the target state; we do not design a full FIDO2 attestation verification pipeline in depth
- Device posture and network trust signals are consumed as inputs to the risk engine, not designed here; we assume an existing endpoint detection and response feed supplies them
High-Level Architecture
The system has six major components: the SAML/OIDC Federation Gateway, the Identity Provider Core, the Session and MFA Policy Engine, the SCIM Provisioning and Deprovisioning Engine, Directory Sync ingesting from the HR feed, and the Admin Console and Audit Log.
An employee’s browser is redirected to the Federation Gateway the moment they try to reach one of the 500 connected apps, carrying either a SAML AuthnRequest or an OIDC authorization request depending on which protocol that app speaks. The Gateway terminates the protocol-specific handshake and hands the actual work of proving who the employee is to the Identity Provider Core, which checks the submitted credential (password hash or WebAuthn assertion) against the central identity store. Once the credential itself checks out, the Session and MFA Policy Engine evaluates a risk score from signals like new device, new geography, and the sensitivity tier of the app being accessed, and decides whether the existing session is sufficient or whether a step-up MFA challenge is required before the Gateway is allowed to issue a SAML assertion or an OIDC ID token back to the app.
In parallel, Directory Sync ingests employee lifecycle events (hires, transfers, terminations) from the HR feed and writes them into the identity store, and any state change there, especially a disable, is picked up immediately by the SCIM Provisioning and Deprovisioning Engine, which fans that event out as a PATCH request to every one of the (on average, per employee) dozens of apps that employee has access to, tracking delivery per app until every one acknowledges or the event is escalated. The Admin Console sits alongside this as the interface administrators use to register new apps, configure MFA and session policy per sensitivity tier, and review the immutable audit log that every other component writes to.
Two loops run at very different rhythms here: a fast, synchronous login loop touching the Gateway, the IdP Core, and the Policy Engine on every one of roughly 1.5 million logins a day, and a slower but latency-critical lifecycle loop where a single HR event has to fan out to hundreds of apps and be confirmed within a five-second window, regardless of how many apps that employee happened to have access to.
The single most important architectural decision is that credential verification happens in exactly one place, the Identity Provider Core, and every connected app receives only a signed assertion or token, never a password or a direct database query against the identity store. This is what makes the system federatable at all: 500 apps can each independently decide to trust the IdP’s signature without any of them needing direct access to, or direct trust in, one another.
The Identity Provider Core
This component’s job is to be the single place where a credential is actually checked, so that every other part of the system, including 500 independently operated apps, can trust an assertion without ever seeing a password.
The instinct to let each connected app verify credentials directly against a shared LDAP directory looks simpler at first, but it means 500 separate systems each need direct network access to the credential store and each become an attack surface against it. The IdP Core instead exposes no credential-checking interface to the outside world at all; only the Federation Gateway calls into it, over an internal-only channel, and it returns a boolean plus a risk-relevant signal (new device, failed attempt count), never the underlying secret.
# Identity Provider Core: credential verification for password and WebAuthn passkey factors
# Demonstrates: constant-time password check via Argon2id, WebAuthn assertion signature verification
import time
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes
from cryptography.exceptions import InvalidSignature
ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)
class AuthenticationFailed(Exception):
pass
def verify_password(stored_hash: str, candidate: str) -> bool:
# argon2-cffi runs in constant time relative to the candidate, which prevents
# timing side channels from leaking how many characters matched.
try:
ph.verify(stored_hash, candidate)
return True
except VerifyMismatchError:
return False
def verify_webauthn_assertion(
public_key_bytes: bytes,
authenticator_data: bytes,
client_data_hash: bytes,
signature: bytes,
expected_sign_count: int,
presented_sign_count: int,
) -> bool:
# Reject a replayed assertion outright: a WebAuthn authenticator's sign counter
# must strictly increase on every use, or the credential may have been cloned.
if presented_sign_count <= expected_sign_count:
raise AuthenticationFailed("sign_count did not increase, possible cloned credential")
public_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), public_key_bytes)
signed_data = authenticator_data + client_data_hash
try:
public_key.verify(signature, signed_data, ec.ECDSA(hashes.SHA256()))
return True
except InvalidSignature:
return False
def check_credential(user_record: dict, factor_type: str, candidate: dict, now: float | None = None) -> bool:
now = now or time.time()
if user_record["status"] != "active":
raise AuthenticationFailed(f"account status is {user_record['status']}, not active")
if factor_type == "password":
return verify_password(user_record["password_hash"], candidate["password"])
if factor_type == "webauthn":
return verify_webauthn_assertion(
user_record["webauthn_public_key"],
candidate["authenticator_data"],
candidate["client_data_hash"],
candidate["signature"],
user_record["webauthn_sign_count"],
candidate["sign_count"],
)
raise AuthenticationFailed(f"unsupported factor type: {factor_type}")
Think of the IdP Core as the hotel’s front desk safe where the master keys actually live: readers out on the floor never see the master key itself, they only ever see a signal the front desk chose to send them. Skip this centralization and let apps validate credentials themselves, and a single compromised app’s database becomes a credential-harvesting operation against every other app the same employees use, since password reuse across a shared identity is exactly the assumption SSO is supposed to eliminate.
Storing a WebAuthn sign counter and then not actually checking it on every assertion is a common shortcut that quietly disables the one signal that detects a cloned authenticator. A cloned hardware key produces a valid signature every time; only a sign counter that fails to strictly increase reveals that two authenticators are now claiming to be the same credential.
The SAML/OIDC Federation Gateway
This component’s job is to speak two structurally different federation protocols fluently to 500 relying parties, translating each one’s handshake into a single internal call to the IdP Core and Policy Engine, and translating the result back into whatever assertion format that specific app expects.
The tempting simplification is to treat SAML as a legacy format to be phased out and build only for OIDC, but SAML remains the default for a large fraction of enterprise SaaS, especially older or compliance-heavy vendors, so the Gateway has to support both as first-class citizens rather than bolting SAML on as an afterthought. The two protocols diverge in almost every practical detail: SAML pushes an XML assertion through the browser via a POST binding, while OIDC exchanges an authorization code for a JWT over a direct back-channel call, which means the Gateway’s SAML path and OIDC path share a decision core but almost nothing at the wire-protocol layer.
# SAML Federation Gateway: building and signing a SAML 2.0 Response for SP-initiated login
# Demonstrates: assertion construction, audience restriction, XML signing with signxml
from datetime import datetime, timedelta, timezone
from lxml import etree
from lxml.builder import ElementMaker
from signxml import XMLSigner, methods
SAML_NS = "urn:oasis:names:tc:SAML:2.0:assertion"
SAMLP_NS = "urn:oasis:names:tc:SAML:2.0:protocol"
E = ElementMaker(namespace=SAML_NS, nsmap={"saml": SAML_NS})
EP = ElementMaker(namespace=SAMLP_NS, nsmap={"samlp": SAMLP_NS, "saml": SAML_NS})
def build_saml_response(
idp_entity_id: str,
sp_entity_id: str,
acs_url: str,
in_response_to: str,
subject_email: str,
attributes: dict[str, str],
signing_key,
signing_cert,
) -> bytes:
now = datetime.now(timezone.utc)
not_on_or_after = now + timedelta(minutes=5)
assertion_id = f"_{now.timestamp():.0f}-assertion"
attribute_statements = [
E.Attribute(E.AttributeValue(value), Name=name)
for name, value in attributes.items()
]
assertion = E.Assertion(
E.Issuer(idp_entity_id),
E.Subject(
E.NameID(subject_email, Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"),
E.SubjectConfirmation(
E.SubjectConfirmationData(
Recipient=acs_url,
NotOnOrAfter=not_on_or_after.isoformat(),
InResponseTo=in_response_to,
),
Method="urn:oasis:names:tc:SAML:2.0:cm:bearer",
),
),
E.Conditions(
E.AudienceRestriction(E.Audience(sp_entity_id)),
NotBefore=now.isoformat(),
NotOnOrAfter=not_on_or_after.isoformat(),
),
E.AttributeStatement(*attribute_statements),
ID=assertion_id,
IssueInstant=now.isoformat(),
Version="2.0",
)
response = EP.Response(
EP.Issuer(idp_entity_id),
assertion,
ID=f"_{now.timestamp():.0f}-response",
InResponseTo=in_response_to,
IssueInstant=now.isoformat(),
Version="2.0",
Destination=acs_url,
)
signer = XMLSigner(method=methods.enveloped, signature_algorithm="rsa-sha256", digest_algorithm="sha256")
signed = signer.sign(response, key=signing_key, cert=signing_cert)
return etree.tostring(signed)
// OIDC Federation Gateway: signing an ID token for the Authorization Code flow
// Demonstrates: RS256 JWT signing with golang-jwt, standard OIDC claim set
package gateway
import (
"crypto/rsa"
"time"
"github.com/golang-jwt/jwt/v5"
)
type IDTokenClaims struct {
jwt.RegisteredClaims
Email string `json:"email"`
Groups []string `json:"groups"`
Nonce string `json:"nonce,omitempty"`
AuthTime int64 `json:"auth_time"`
AMR []string `json:"amr"`
}
func IssueIDToken(signingKey *rsa.PrivateKey, keyID, issuer, audience, subject string, groups []string, nonce string, mfaSatisfied bool, authTime time.Time) (string, error) {
now := time.Now().UTC()
amr := []string{"pwd"}
if mfaSatisfied {
amr = append(amr, "mfa")
}
claims := IDTokenClaims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: issuer,
Subject: subject,
Audience: jwt.ClaimStrings{audience},
ExpiresAt: jwt.NewNumericDate(now.Add(1 * time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
Groups: groups,
Nonce: nonce,
AuthTime: authTime.Unix(),
AMR: amr,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = keyID
return token.SignedString(signingKey)
}
A federation gateway is like a translator working a diplomatic summit, taking the same verified decision, “this person is who they claim to be, and they have cleared this risk bar,” and rendering it in whichever language, SAML or OIDC, the counterpart in the room actually speaks. Skip the shared internal decision core and let each protocol path independently decide whether to trust a session, and the two paths inevitably drift out of sync on what “authenticated enough” means, creating a gap where an OIDC app enforces step-up MFA that the SAML path quietly skips for the same risk profile.
Okta and Microsoft Entra ID both operate exactly this kind of dual-protocol gateway internally, translating one authenticated session into whichever assertion format each connected application expects, which is precisely why enterprises can mix decades-old SAML-only vendors with brand-new OIDC-native SaaS products behind a single sign-on experience without either protocol becoming a second-class citizen.
The SCIM Provisioning and Deprovisioning Engine
This component’s job is to take one lifecycle change, most urgently a disable, and guarantee it reaches every one of the potentially hundreds of apps a given employee has access to, within a five-second SLA, even when some of those apps’ own SCIM endpoints are slow or occasionally down.
The naive design processes provisioning events one app at a time in a simple loop, which works fine for a handful of apps but collapses the moment a single employee has access to 40 or 50 of the 500 connected apps and one of those apps happens to be slow: a synchronous, sequential loop means the slowest app in the list determines how long every other app waits to be notified. The Engine instead fans a single lifecycle event out to a per-app worker pool immediately, tracks delivery independently per app, and applies per-app circuit breakers so one consistently failing app’s retries never starve capacity meant for the other 499.
# SCIM Provisioning Engine: concurrent fan-out with per-app circuit breaker and retry
# Demonstrates: async fan-out, exponential backoff, idempotent SCIM PATCH delivery
import asyncio
import time
import httpx
class CircuitOpen(Exception):
pass
class AppCircuitBreaker:
def __init__(self, failure_threshold: int = 5, reset_after_seconds: int = 60):
self.failure_threshold = failure_threshold
self.reset_after_seconds = reset_after_seconds
self.consecutive_failures = 0
self.opened_at: float | None = None
def record_success(self) -> None:
self.consecutive_failures = 0
self.opened_at = None
def record_failure(self, now: float) -> None:
self.consecutive_failures += 1
if self.consecutive_failures >= self.failure_threshold:
self.opened_at = now
def allow_request(self, now: float) -> bool:
if self.opened_at is None:
return True
if now - self.opened_at >= self.reset_after_seconds:
return True # half-open: let one probe request through
return False
async def deliver_scim_patch(
client: httpx.AsyncClient,
app: dict,
external_user_id: str,
event_id: str,
active: bool,
breaker: AppCircuitBreaker,
max_attempts: int = 5,
) -> dict:
now = time.time()
if not breaker.allow_request(now):
raise CircuitOpen(f"circuit open for app {app['app_id']}")
payload = {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [{"op": "replace", "path": "active", "value": active}],
}
headers = {
"Authorization": f"Bearer {app['scim_bearer_token']}",
"Content-Type": "application/scim+json",
"X-Idempotency-Key": event_id, # safe to retry, app-side dedupe on this key
}
url = f"{app['scim_endpoint_url']}/Users/{external_user_id}"
delay = 0.25
last_error: Exception | None = None
for attempt in range(1, max_attempts + 1):
try:
resp = await client.patch(url, json=payload, headers=headers, timeout=2.0)
if resp.status_code in (200, 204):
breaker.record_success()
return {"app_id": app["app_id"], "status": "acked", "attempts": attempt}
if resp.status_code == 429:
delay = float(resp.headers.get("Retry-After", delay))
else:
resp.raise_for_status()
except (httpx.HTTPError, httpx.TimeoutException) as exc:
last_error = exc
breaker.record_failure(time.time())
await asyncio.sleep(delay)
delay = min(delay * 2, 4.0)
return {"app_id": app["app_id"], "status": "dead_letter", "attempts": max_attempts, "error": str(last_error)}
async def fan_out_disable(client: httpx.AsyncClient, event_id: str, apps: list[dict], breakers: dict[str, AppCircuitBreaker]) -> list[dict]:
tasks = [
deliver_scim_patch(client, app, app["external_user_id"], event_id, active=False, breaker=breakers[app["app_id"]])
for app in apps
]
return await asyncio.gather(*tasks, return_exceptions=False)
This is the same reasoning a fire alarm system uses: it does not ring one floor, wait for confirmation, then ring the next floor in sequence. It triggers every floor’s alarm concurrently, and a jammed bell on one floor never delays the alarm reaching every other floor. Without concurrent fan-out and per-app isolation, one app with a slow or rate-limited SCIM endpoint would single-handedly blow the five-second SLA for an employee’s other 39 apps, even though those apps were ready to acknowledge the disable in milliseconds.
Treating a SCIM 200 OK as proof the app actually disabled the account is a common and dangerous shortcut. Some SCIM implementations accept and acknowledge a PATCH request before fully applying it internally, or silently ignore fields they do not recognize. A trustworthy provisioning pipeline periodically reconciles by reading the account state back via SCIM GET, not just trusting the write acknowledgment.
The Session and MFA Policy Engine
This component’s job is to decide, on every login and on sensitive in-app actions, whether the proof of identity already presented is strong enough for what is being accessed, and to demand more proof exactly when it is not, rather than applying the same MFA bar everywhere.
A flat policy, “MFA required for everyone, every time,” sounds maximally secure but trains employees to click through MFA prompts reflexively, which is precisely the behavior that makes MFA fatigue attacks (repeatedly pushing approval prompts until someone taps accept out of habit) effective. The Policy Engine instead computes a risk score per login attempt from a small set of weighted signals and only forces a step-up challenge when that score, combined with the sensitivity tier of the app being accessed, crosses a threshold.
# Session and MFA Policy Engine: risk-based adaptive MFA decision
# Demonstrates: weighted signal scoring, per-app sensitivity tier thresholds
from dataclasses import dataclass
@dataclass
class RiskSignals:
known_device: bool
known_geo: bool
impossible_travel: bool # geo-velocity check against the user's last login
device_posture_ok: bool # EDR agent reports the device as compliant
failed_attempts_last_hour: int
SIGNAL_WEIGHTS = {
"unknown_device": 30,
"unknown_geo": 20,
"impossible_travel": 50,
"device_posture_bad": 25,
"recent_failures": 10, # per failed attempt, capped
}
SENSITIVITY_THRESHOLDS = {1: 70, 2: 40, 3: 15} # tier 3 (finance, admin consoles) demands MFA at low risk
def compute_risk_score(signals: RiskSignals) -> int:
score = 0
if not signals.known_device:
score += SIGNAL_WEIGHTS["unknown_device"]
if not signals.known_geo:
score += SIGNAL_WEIGHTS["unknown_geo"]
if signals.impossible_travel:
score += SIGNAL_WEIGHTS["impossible_travel"]
if not signals.device_posture_ok:
score += SIGNAL_WEIGHTS["device_posture_bad"]
score += min(signals.failed_attempts_last_hour, 3) * SIGNAL_WEIGHTS["recent_failures"]
return score
def requires_mfa(signals: RiskSignals, app_sensitivity_tier: int, session_mfa_satisfied: bool, session_age_seconds: int, max_session_age_seconds: int = 28800) -> bool:
if session_age_seconds > max_session_age_seconds:
return True # session too old regardless of risk; force full re-authentication
if not session_mfa_satisfied:
return True # no prior MFA on this session at all
threshold = SENSITIVITY_THRESHOLDS.get(app_sensitivity_tier, 40)
return compute_risk_score(signals) >= threshold
Think of this like an airport security line that waves frequent, pre-vetted travelers through a fast lane but pulls someone aside for extra screening the moment something about their itinerary looks unusual, rather than pat-searching every single passenger regardless of risk. Without adaptive scoring, a security team either over-challenges low-risk logins into user fatigue or under-challenges high-risk ones into a false sense of coverage; neither uniform policy actually matches risk to friction.
This is the same risk-based approach Google popularized through its BeyondCorp model and that most modern identity platforms, including Microsoft Entra ID’s Conditional Access and Okta’s Adaptive MFA, now ship as a default: authentication strength scales with computed risk and resource sensitivity rather than being a single fixed bar applied uniformly to every login.
Session lifetime is the other half of this component’s job, and it deliberately is not a single global timeout. Each app registers its own default session TTL and idle timeout based on its own sensitivity tier, so a low-risk internal wiki can carry an 8-hour session while a production admin console can be configured to demand re-authentication every 30 minutes regardless of activity.
# MFA and session policy configuration, evaluated per app sensitivity tier
policies:
- tier: 1
label: "low-sensitivity (wikis, internal tools)"
mfa_risk_threshold: 70
session_ttl_seconds: 28800
idle_timeout_seconds: 3600
step_up_required_for: []
- tier: 2
label: "standard business apps (CRM, ticketing)"
mfa_risk_threshold: 40
session_ttl_seconds: 14400
idle_timeout_seconds: 1800
step_up_required_for: ["export_data", "bulk_delete"]
- tier: 3
label: "sensitive (finance, admin consoles, HR systems)"
mfa_risk_threshold: 15
session_ttl_seconds: 1800
idle_timeout_seconds: 600
step_up_required_for: ["any_write_action"]
Session lifetime and MFA policy are both properties of the app being accessed, not global platform constants, because the cost of an over-long session on a low-sensitivity wiki and the cost of the same session length on a finance admin console are not remotely comparable. Centralizing policy configuration while letting the actual values vary per app sensitivity tier is what makes both usability and security tractable at 500 apps.
Data Model
The durable state splits into five parts: the identity store, MFA factor enrollments, app registrations, active sessions, and the provisioning event queue.
-- Central identity store: system of record for every employee and contractor
CREATE TABLE users (
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
employee_id TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
department TEXT,
manager_user_id UUID REFERENCES users(user_id),
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended', 'disabled', 'deleted')),
password_hash TEXT,
password_algo TEXT NOT NULL DEFAULT 'argon2id',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
disabled_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON users (status) WHERE status != 'active';
CREATE INDEX ON users (manager_user_id);
-- Enrolled MFA factors, one row per credential (a user may hold several)
CREATE TABLE mfa_factors (
factor_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(user_id),
factor_type TEXT NOT NULL CHECK (factor_type IN ('webauthn', 'totp', 'sms', 'push')),
credential_id BYTEA,
public_key BYTEA,
sign_count BIGINT NOT NULL DEFAULT 0,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX ON mfa_factors (user_id) WHERE is_primary;
-- Registered relying parties, one row per connected app, either protocol
CREATE TABLE app_registrations (
app_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_name TEXT NOT NULL,
protocol TEXT NOT NULL CHECK (protocol IN ('saml2', 'oidc')),
entity_id_or_client_id TEXT UNIQUE NOT NULL,
acs_url_or_redirect_uris TEXT[] NOT NULL,
metadata_xml_or_jwks_uri TEXT,
sensitivity_tier SMALLINT NOT NULL DEFAULT 1 CHECK (sensitivity_tier BETWEEN 1 AND 3),
scim_endpoint_url TEXT,
scim_bearer_token_secret_ref TEXT,
jit_provisioning_enabled BOOLEAN NOT NULL DEFAULT TRUE,
default_session_ttl_seconds INTEGER NOT NULL DEFAULT 28800,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Per-app external identity mapping, needed for SCIM PATCH targeting and JIT lookups
CREATE TABLE external_identities (
external_identity_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(user_id),
app_id UUID NOT NULL REFERENCES app_registrations(app_id),
external_user_id TEXT NOT NULL,
provisioning_state TEXT NOT NULL DEFAULT 'pending'
CHECK (provisioning_state IN ('pending', 'active', 'disabled', 'deprovisioned')),
last_synced_at TIMESTAMPTZ,
UNIQUE (user_id, app_id)
);
CREATE INDEX ON external_identities (app_id, provisioning_state);
-- Active sessions, partitioned by month since only recent partitions stay hot
CREATE TABLE sessions (
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(user_id),
app_id UUID REFERENCES app_registrations(app_id),
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
idle_timeout_seconds INTEGER NOT NULL DEFAULT 900,
last_active_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
mfa_satisfied BOOLEAN NOT NULL DEFAULT FALSE,
risk_score SMALLINT,
revoked_at TIMESTAMPTZ
) PARTITION BY RANGE (issued_at);
CREATE INDEX ON sessions (user_id) WHERE revoked_at IS NULL;
-- Provisioning and deprovisioning event queue, one row per (event, app) delivery attempt group
CREATE TABLE provisioning_events (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(user_id),
app_id UUID NOT NULL REFERENCES app_registrations(app_id),
event_type TEXT NOT NULL CHECK (event_type IN ('create', 'update', 'disable', 'delete')),
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'in_flight', 'acked', 'failed', 'dead_letter')),
attempt_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
acked_at TIMESTAMPTZ,
next_retry_at TIMESTAMPTZ
);
CREATE INDEX ON provisioning_events (status, next_retry_at) WHERE status IN ('queued', 'failed');
CREATE INDEX ON provisioning_events (user_id, created_at DESC);
sessions is range-partitioned by issued_at in monthly partitions because only the last two or three partitions are ever queried for live validation; older partitions exist purely for audit and can be dropped or moved to cold storage on a fixed schedule. provisioning_events is indexed on (status, next_retry_at) specifically to make the retry worker’s polling query, “give me everything queued or failed that is due for another attempt,” a fast index scan rather than a table scan, which matters directly for hitting the five-second propagation SLA under a burst of simultaneous disables.
A login moves through four states in milliseconds: request received, credential verified, risk scored (with an optional MFA detour), and assertion issued. A disable event moves through a parallel and much more consequential path: state changed in the identity store, fan-out queued, delivered per app, and finally either acknowledged or escalated to a human when an app fails to confirm within the retry budget.
Separating external_identities.provisioning_state from users.status is deliberate: a user can be disabled centrally the instant HR says so, while individual apps’ provisioning_state values lag briefly and independently as SCIM delivery completes per app. Modeling that lag explicitly, instead of pretending deprovisioning is instantaneous everywhere, is what makes the five-second SLA measurable and alertable rather than an unverifiable assumption.
Key Algorithms and Protocols
SAML Assertion Validation
Verifying an inbound SAML Response correctly is not just checking a signature; it means checking the signature, the audience restriction, the validity window, and the response-to-request binding together, because skipping any one of them individually reopens an attack the others were specifically designed to close.
# SAML Response validation: signature, audience, timing window, and replay checks
# Demonstrates: defense against assertion replay and audience confusion attacks
from datetime import datetime, timezone
from lxml import etree
from signxml import XMLVerifier
from signxml.exceptions import InvalidSignature
SAML_NS = {"saml": "urn:oasis:names:tc:SAML:2.0:assertion"}
class InvalidAssertion(Exception):
pass
def validate_saml_response(
response_xml: bytes,
idp_cert,
expected_audience: str,
expected_in_response_to: str,
seen_assertion_ids: set[str],
clock_skew_seconds: int = 60,
) -> dict:
root = etree.fromstring(response_xml)
try:
verified = XMLVerifier().verify(root, x509_cert=idp_cert).signed_xml
except InvalidSignature as exc:
raise InvalidAssertion(f"signature verification failed: {exc}")
assertion = verified.find(".//saml:Assertion", namespaces=SAML_NS)
assertion_id = assertion.get("ID")
if assertion_id in seen_assertion_ids:
raise InvalidAssertion("assertion ID already seen, possible replay attack")
conditions = assertion.find("saml:Conditions", namespaces=SAML_NS)
not_before = datetime.fromisoformat(conditions.get("NotBefore"))
not_on_or_after = datetime.fromisoformat(conditions.get("NotOnOrAfter"))
now = datetime.now(timezone.utc)
if not (not_before.timestamp() - clock_skew_seconds <= now.timestamp() <= not_on_or_after.timestamp() + clock_skew_seconds):
raise InvalidAssertion("assertion is outside its validity window")
audience = conditions.find(".//saml:Audience", namespaces=SAML_NS).text
if audience != expected_audience:
raise InvalidAssertion(f"audience mismatch: expected {expected_audience}, got {audience}")
subject_conf = assertion.find(".//saml:SubjectConfirmationData", namespaces=SAML_NS)
if subject_conf.get("InResponseTo") != expected_in_response_to:
raise InvalidAssertion("InResponseTo does not match the original AuthnRequest ID")
seen_assertion_ids.add(assertion_id)
name_id = assertion.find(".//saml:NameID", namespaces=SAML_NS).text
return {"subject": name_id, "assertion_id": assertion_id}
The edge case that catches most first implementations is audience confusion: without checking AudienceRestriction against the specific SP the assertion is being presented to, an assertion legitimately issued for one low-trust app could be replayed against a higher-trust app, since both apps trust the same IdP signature and neither one alone can tell the assertion was meant for someone else.
The property that makes SAML validation trustworthy is that every check, signature, timing window, audience, and InResponseTo, is independently necessary and none of them substitutes for another. A valid signature only proves the IdP issued the assertion; it says nothing about who it was issued for or whether it has already been used, which is exactly why replay tracking and audience restriction exist as separate, mandatory checks.
OIDC Authorization Code Flow with PKCE
PKCE exists to close a specific gap in the OAuth 2.0 Authorization Code flow: a public client (a browser-based SPA or a mobile app that cannot keep a client secret confidential) would otherwise let anyone who intercepts the authorization code exchange it for tokens themselves.
// OIDC PKCE: code verifier generation and code challenge derivation
// Demonstrates: cryptographically random verifier, S256 challenge method per RFC 7636
package pkce
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
)
func GenerateCodeVerifier() (string, error) {
buf := make([]byte, 32) // 256 bits of entropy, well above RFC 7636's 128-bit minimum
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func DeriveCodeChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// At token exchange time, the Gateway recomputes the challenge from the presented
// verifier and compares it to the challenge stored against the original auth code.
// A stolen auth code alone is useless without the verifier that only the original
// client ever held, which never left that client until this exact exchange.
func VerifyCodeChallenge(verifier, storedChallenge string) bool {
return DeriveCodeChallenge(verifier) == storedChallenge
}
Time complexity for both generating a verifier and deriving its challenge is O(1), independent of anything else in the system, which is exactly why PKCE adds negligible overhead to the login path while closing an entire class of authorization code interception attacks. The edge case worth naming: a client that reuses the same code verifier across multiple login attempts weakens the protection back down to roughly the same as not using PKCE at all, since an intercepted code and a predictable verifier together are just as exploitable as an intercepted code alone.
The property that makes PKCE work is that the code challenge sent up front is a one-way function of a secret the client never transmits until the final token exchange, so intercepting the authorization request or the redirect alone gives an attacker nothing usable. Public clients get confidential-client-grade protection without ever needing to hold a client secret.
Just-In-Time Provisioning
For apps configured with jit_provisioning_enabled, the first federated login for a user who has no existing external_identities row triggers account creation inline, using attributes carried in the SAML assertion or OIDC ID token, rather than waiting for a separate SCIM push to have run first.
# Just-in-time provisioning: create the external identity on first federated login
# Demonstrates: idempotent upsert keyed on (user_id, app_id), attribute mapping from the assertion
def jit_provision(db, app: dict, user_id: str, assertion_attributes: dict) -> dict:
existing = db.fetch_one(
"SELECT external_identity_id, provisioning_state FROM external_identities WHERE user_id = %s AND app_id = %s",
(user_id, app["app_id"]),
)
if existing:
if existing["provisioning_state"] == "disabled":
raise PermissionError("account exists at this app but is disabled; will not silently re-enable via JIT")
return existing
if not app["jit_provisioning_enabled"]:
raise PermissionError("no existing account and JIT provisioning is disabled for this app")
external_user_id = assertion_attributes.get("external_id") or assertion_attributes["email"]
row = db.fetch_one(
"""
INSERT INTO external_identities (user_id, app_id, external_user_id, provisioning_state, last_synced_at)
VALUES (%s, %s, %s, 'active', NOW())
ON CONFLICT (user_id, app_id) DO NOTHING
RETURNING external_identity_id, provisioning_state
""",
(user_id, app["app_id"], external_user_id),
)
return row
The critical edge case is the disabled branch: JIT provisioning must never treat a previously deprovisioned account as eligible for silent re-creation just because a new login attempt arrives. Without that check, an employee disabled at one app for a policy violation, but still active centrally for other apps, could simply re-trigger JIT and get a fresh account at the very app they were removed from.
JIT provisioning and SCIM push provisioning are not competing designs, they are complementary paths keyed on the same external_identities row: JIT handles the common case of “grant access the moment someone needs it,” while SCIM push handles apps that require an account to exist before first login, and both converge on identical provisioning_state semantics so downstream deprovisioning logic never needs to know which path created the account.
Scaling and Performance
The Federation Gateway and IdP Core scale horizontally as stateless pods behind a regional load balancer, since credential verification and assertion signing carry no session-affinity requirement once the identity store and signing keys are reachable. The identity store itself runs as a single-writer primary with regional read replicas, since writes (new hires, transfers, disables) are a small fraction of traffic compared to reads (every login checks status), but a disable event is deliberately routed through a synchronous fast-path broadcast to every region rather than waiting on normal replica lag, because ordinary async replication latency, typically under a second but not bounded, is not an acceptable way to guarantee a five-second worst-case SLA.
Capacity Estimation:
Given:
- 250,000 employees, 500 connected apps, average 40 apps/employee has-access-to
- Average 6 SSO-mediated logins/employee/day, peak login-storm window: 20% of daily
logins land in a 30-minute regional business-hours-start burst
- Session record: ~300 bytes; provisioning event record: ~250 bytes
- SCIM fan-out: average employee has access to 40 of 500 apps
Login throughput:
Daily logins: 250,000 * 6 = 1,500,000 logins/day
Peak burst: 1,500,000 * 0.20 = 300,000 logins in 1,800s ~= 167 logins/sec sustained peak
Instantaneous spikes 3x sustained peak ~= 500 logins/sec, comfortably under the 150ms p99 budget
with Gateway/IdP Core pods scaled to roughly 20 pods/region at ~30 logins/sec/pod headroom
Session validation:
Self-contained JWTs (OIDC) validate locally at each app with zero round trip to the IdP
Opaque SAML-style sessions requiring introspection: ~5% of apps, ~80,000 concurrent active
sessions at peak, introspected roughly once every 60s each ~= 80,000 * 0.05 / 60 ~= 67 calls/sec
Deprovisioning fan-out (worst case, bulk offboarding event):
2,000 accounts disabled in one HR batch (for example, a reduction in force)
2,000 * 40 apps/employee = 80,000 SCIM PATCH calls owed within the 5-second SLA
80,000 calls / 5s = 16,000 SCIM calls/sec required sustained for that burst window
Sized to 200 fan-out workers at 80 calls/sec/worker, each worker independent per app shard
Storage:
Sessions: ~80,000 concurrent * 300 bytes ~= 24 MB live working set, trivial
Provisioning events: 1,500,000 logins/day generate few new events; steady-state lifecycle
changes plus periodic reconciliation sweeps average ~200,000 events/day * 250 bytes ~= 50 MB/day
Audit log (all logins + all provisioning attempts) at ~500 bytes/event, 7-year compliance
retention: (1,500,000 + 200,000) * 500B * 365 * 7 ~= 2.2 TB over the retention window
The read-to-write ratio is heavily skewed toward reads on the identity store (a login checks status far more often than status ever changes) but heavily skewed toward writes on the provisioning queue during an offboarding burst, which is exactly why the two get different scaling treatments: read replicas and caching for the login path, and a wide, independently-scalable worker pool for the fan-out path. We cache app_registrations and MFA policy configuration aggressively at the Gateway, since those change on the order of once a week per app, not once a second, and a stale cache entry there is far cheaper than an extra database round trip on every one of 1.5 million daily logins.
This mirrors how large identity platforms like Auth0 and Ping Identity architect their own multi-tenant fleets: stateless, horizontally scaled authentication front doors backed by a much smaller, carefully protected identity data tier, with the two scaled independently because their load profiles, read-heavy login checks versus write-heavy lifecycle events, genuinely do not move together.
Failure Modes and Recovery
| Failure | Detection | Impact | Recovery |
|---|---|---|---|
| Federation Gateway or IdP Core pod crash mid-login | Load balancer health check fails; in-flight request returns an error to the browser | The employee retries the login; no partial session state is ever left behind since sessions are only written after a full successful auth decision | Load balancer routes new requests to healthy pods; the crashed pod is replaced automatically, and the login is simply retried by the client, safely, since nothing was partially committed |
| SAML or OIDC signing key compromise | HSM audit log shows key usage from an unexpected source, or an anomaly detection alert on assertion issuance volume | Attacker could forge assertions for any of the 500 apps until rotation completes | Immediate key rotation with the overlap window honored; every app’s cached metadata or JWKS refreshes within the documented window, and the compromised key’s usage window is treated as a security incident scoped by timestamp |
| A subscribed app’s SCIM endpoint is down or slow | Circuit breaker trips after consecutive failures; reconciliation sweep flags accounts stuck in pending past the SLA window | Deprovisioning for that specific app lags past the 5-second target; other apps are unaffected because delivery is per-app and concurrent | Retries continue with exponential backoff until the circuit half-opens; if the SLA is breached, an alert pages the on-call identity engineer with the specific app and affected user list |
| Clock skew between the IdP and a connected app validating assertion timing windows | App-side SAML libraries reject assertions as expired or not-yet-valid even though they were issued correctly | Legitimate logins fail intermittently at the affected app | The Gateway applies a bounded clock-skew tolerance (typically 60 seconds) when setting NotBefore/NotOnOrAfter; persistent rejection triggers an alert to check the affected app’s own NTP configuration |
| Session store (Redis) region failure during a login-storm peak | Redis cluster health probe fails; write and read latency spike on the affected shard | New session issuance in that region degrades or fails; existing sessions elsewhere are unaffected | Traffic fails over to the region’s Redis replica promoted to primary; the Gateway falls back to a short-lived in-memory grace mode that still enforces MFA policy but queues session writes until the store recovers |
| HR feed delivers a duplicate or out-of-order lifecycle event | Directory Sync deduplicates on a monotonic HR event sequence number per employee; an out-of-order disable-before-create is rejected | Without dedupe, a duplicate disable event would be harmless, but an out-of-order event could otherwise re-enable a legitimately disabled account | Events are applied only if their sequence number is greater than the last applied sequence number for that employee; anything older is logged and discarded, never applied |
The most common operational mistake is treating a SCIM delivery acknowledgment as equivalent to a confirmed disable without ever reconciling. Under sustained load, teams quietly stop running the reconciliation sweep because it is expensive, and months later discover that a specific app’s SCIM integration has been silently accepting PATCH requests without applying them, leaving a population of supposedly-disabled accounts still fully active at that one app.
Comparison of Approaches
| Approach | Latency | Complexity | Failure Mode | Best Fit |
|---|---|---|---|---|
| No SSO, native per-app credentials | Low per login, no federation round trip | Low per app, very high in aggregate | Deprovisioning requires manually touching every app; near-certain orphaned access on offboarding | Fewer than a handful of apps, or a very small organization |
| LDAP/Active Directory only, no cloud federation | Low for on-prem apps, unsupported for most SaaS | Medium, well-understood but legacy tooling | Cloud SaaS apps cannot integrate at all without a federation bridge; growth stalls | Organizations that are still primarily on-prem with minimal SaaS footprint |
| Buy a managed IDaaS (Okta, Entra ID, Ping) | Low, vendor-optimized | Low, mostly configuration rather than engineering | Vendor lock-in; limited control over custom risk signals and propagation SLA tuning | Most organizations, especially those without a dedicated identity engineering team |
| Build a custom federated IdP (this design) | Low, tuned specifically to internal latency and SLA targets | High, requires dedicated ownership of protocol correctness and key management | Full responsibility for security posture; a design flaw affects all 500 apps at once | Large enterprises with unique compliance, scale, or integration requirements a vendor cannot meet |
| SAML-only federation, no OIDC support | Low for legacy apps, requires a workaround for OIDC-native apps | Medium, single protocol is simpler to operate | Modern SaaS vendors that only offer OIDC either cannot integrate or need a costly bolt-on translation layer | Organizations whose app portfolio is overwhelmingly legacy SAML-only vendors |
For an organization running 250,000 identities across 500 apps with a hard deprovisioning SLA, building a custom federated IdP is the right call specifically because the deprovisioning propagation requirement, sub-five-second acknowledgment across hundreds of independently operated SCIM endpoints, is exactly the kind of latency-tuned, deeply operational requirement that off-the-shelf IDaaS platforms support only at their own default service levels, not a custom-tuned one. A smaller organization without that specific requirement, or without the appetite to own protocol correctness and key management directly, is almost always better served buying a managed platform; the build path only pays for itself once integration count and SLA specificity both cross a real threshold.
Key Takeaways
- Centralize credential verification, never centralize the credential itself: the IdP Core is the only place a password or WebAuthn assertion is ever checked, and every one of the 500 apps receives only a signed, protocol-specific assertion, never a database query.
- SAML and OIDC are translated by a shared decision core, not by parallel, independently evolving code paths: keeping risk scoring and MFA policy protocol-agnostic prevents the two federation paths from silently drifting apart on what “authenticated enough” means.
- Deprovisioning is a fan-out problem, not a loop: concurrent, per-app delivery with independent circuit breakers is what keeps one slow app’s SCIM endpoint from blowing the SLA for another employee’s 39 other apps.
- Adaptive MFA beats uniform MFA: scoring risk per login and demanding step-up only when it is warranted avoids the prompt fatigue that makes users click “approve” reflexively on push-based MFA fatigue attacks.
- Session lifetime is a property of the app, not the platform: a finance admin console and an internal wiki should never share the same session TTL just because they share the same identity provider.
- JIT and SCIM push provisioning converge on the same state model: both create
external_identitiesrows with identical semantics, so downstream deprovisioning logic never needs to know which path created the account. - Acknowledgment is not confirmation: a
200 OKfrom a SCIM endpoint proves delivery, not application; periodic reconciliation against actual account state is what catches integrations that silently drop what they claim to have applied. - Fail loud on provisioning, never silent: a stuck or failed deprovisioning event that pages a human within seconds is a dramatically better outcome than a disabled account that stays live at one forgotten app for months.
The counter-intuitive lesson is that the hardest part of this system is not the cryptography behind SAML signatures or OIDC tokens, both are decades-old, well-specified protocols. It is that a single login and a single deprovisioning event have wildly different latency and consistency requirements even though they flow through the same identity platform, and treating them as the same kind of problem, rather than designing separate synchronous and fan-out paths for each, is exactly where SSO platforms quietly accumulate the orphaned-access debt that turns into a headline security incident years later.
Frequently Asked Questions
Q: Why not just adopt OIDC everywhere and drop SAML support to simplify the Gateway?
A: A meaningful share of enterprise SaaS vendors, especially older or compliance-heavy ones, still only support SAML, and refusing to federate with them means either abandoning those apps or falling back to native per-app credentials, which reintroduces exactly the deprovisioning gap SSO exists to close. Supporting both protocols through a shared internal decision core costs more engineering effort up front but avoids permanently excluding a large class of vendors.
Q: Why not make every app’s session validation call back to the IdP on every request, to guarantee instant revocation everywhere?
A: That would turn the IdP into a synchronous dependency for every single API call any of the 500 apps make, not just logins, which at 20,000 validations/sec aggregate would multiply IdP load by orders of magnitude and add a network round trip to every request. Self-contained, short-lived JWTs validated locally by each app, combined with a hard cap on session lifetime and a fast-path disable broadcast, achieve a comparable revocation bound without that cost.
Q: How does the system prevent a disabled account from completing a login that was already in progress when the disable happened?
A: The IdP Core checks users.status at the moment of credential verification, not at session creation time only, so a disable that lands even a few hundred milliseconds before the credential check completes is honored immediately. A login already fully completed with an assertion already delivered to the app is handled by the SCIM fan-out disabling that specific app’s account within the five-second SLA, which is the actual boundary the system guarantees.
Q: Why not treat the risk score threshold as a single global constant instead of varying it per app sensitivity tier?
A: A single global threshold forces an uncomfortable choice between annoying low-risk users on low-sensitivity apps or under-protecting high-sensitivity apps like finance and admin consoles. Varying the threshold per sensitivity tier lets the same computed risk score trigger step-up MFA on a tier-3 app while allowing a tier-1 app to wave the same login through, which matches security friction to actual consequence.
Q: What happens if the HR feed itself sends a wrong or premature disable event?
A: The disable is applied and propagated exactly as designed, since the identity platform has no independent way to know an HR event is mistaken, and treating IdP-side heuristics as a check on HR data would create a much harder-to-reason-about failure mode. Correction is handled by HR issuing a new, correctly sequenced re-enable event, which flows through the same provisioning pipeline as any other lifecycle change, restoring access with the same latency guarantees.
Q: Why maintain per-app external identity mappings instead of just using the employee’s email as the universal key across all 500 apps?
A: Many apps allow (or require) a locally-generated user ID distinct from email, some support email changes without preserving identity continuity, and a handful of legacy apps key on something else entirely, like an employee number. Storing an explicit external_user_id per (user_id, app_id) pair means a SCIM PATCH always targets the correct account even after an email change, rather than relying on a value that is not guaranteed stable at every one of 500 independently operated apps.
Interview Questions
Q: Design the deprovisioning path so that disabling one employee’s account is guaranteed to reach all of their connected apps within five seconds, even when some of those apps’ SCIM endpoints are slow or occasionally unavailable.
Expected depth: Discuss concurrent per-app fan-out versus a sequential loop, per-app circuit breakers to isolate a single slow endpoint, exponential backoff with a bounded retry budget, and the distinction between delivery acknowledgment and confirmed application state via periodic reconciliation. Cover what happens when the SLA is genuinely breached for one app, escalation, alerting, and whether other apps’ delivery should ever be allowed to block on one laggard.
Q: Walk through what happens end to end when an employee logs into an OIDC-native app for the first time, including how their account gets created there.
Expected depth: Cover the Authorization Code flow with PKCE, ID token issuance with the correct claim set, and just-in-time provisioning creating the external_identities row from assertion attributes on first login. Discuss the specific edge case of a previously disabled account at that app and why JIT must never silently re-enable it.
Q: How would you design the MFA policy engine to reduce prompt fatigue without weakening security for the most sensitive applications?
Expected depth: Discuss risk-based adaptive scoring from signals like new device, geo-velocity, and device posture, per-app sensitivity tiers with different risk thresholds, and why a uniform “MFA every time” policy is not actually the most secure option once human behavior under repeated prompts is accounted for. Cover how session age interacts with risk score in the final decision.
Q: How do you scale the Federation Gateway and IdP Core to handle a regional login-storm peak without over-provisioning for the average case?
Expected depth: Discuss stateless horizontal scaling of the Gateway and IdP Core behind regional load balancers, read-replica scaling for the identity store given a read-heavy login-check workload, aggressive caching of app registration and policy metadata that changes infrequently, and how peak-to-average ratio should drive autoscaling policy rather than static fleet sizing.
Q: A signing key used for SAML assertions or OIDC ID tokens is suspected to be compromised. Describe the rotation process and what happens to apps that have not yet picked up the new key.
Expected depth: Cover immediate key rotation with a documented overlap window, publishing the new key alongside the old one in SAML metadata or the OIDC JWKS endpoint so apps that poll infrequently do not break, and the tradeoff between a short overlap window (faster containment, more app breakage risk) and a longer one (slower containment, smoother rollover). Discuss how to detect which apps are still validating against the old key.
Premium Content
Unlock the full article along with everything else in the archive — all in one place.