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}")Premium Content
Unlock the full article along with everything else in the archive — all in one place.