Appearance
11.25 Enterprise Identity & SSO Platform
Enterprise Identity and Single Sign-On (SSO) platforms are the gatekeepers of modern corporate networks. They solve a simple problem: how do you let employees use hundreds of SaaS applications without creating hundreds of passwords, while maintaining centralized control over who has access to what?
The Core Identity Problem
Imagine an enterprise with 10,000 employees. Every day, they use Salesforce, Slack, AWS, and internal tools. If every tool manages its own credentials:
- Security Risk: Employees reuse passwords or write them down.
- Onboarding/Offboarding Nightmare: IT has to manually create or revoke 100 accounts when an employee joins or leaves.
- Compliance Failure: No central audit trail of who accessed what and when.
An Identity Provider (IdP) centralizes this. Applications (Service Providers, or SPs) delegate authentication to the IdP.
Requirements
The platform here is an identity provider sold as a service: 2,000 customer organisations and 10 million users, signing in to the SaaS applications their employers have connected.
Functional. Sign users in with a password, a passkey and multi-factor authentication. Act as the identity provider for SAML and OIDC applications. Receive joiners, movers and leavers from each customer's HR system, and push accounts out to applications with SCIM. Manage sessions, including "sign this person out everywhere". Let each organisation set policy (who must use MFA, from which networks). Record everything for audit.
Non-functional, with numbers.
- 99.99% availability, which is about 53 minutes of downtime a year (365.25 days × 24 × 60 × 0.0001). When the identity provider is down, nobody at 2,000 companies can open any application.
- Sign-in completes within 500 ms at p95, not counting the time the person spends typing.
- A leaver loses access everywhere within 5 minutes of HR marking them terminated.
- Audit records are kept for at least a year and exported to each customer on request.
Out of scope today: the admin console, and customer-specific risk-scoring models.
The clarifying questions, and what each answer changes
"Is the identity provider the source of truth for people, or is HR?" Usually HR. Then the IdP must not let admins create employees by hand without an HR record, or the two drift and leavers keep accounts.
"Which applications support SCIM, and which only accept a SAML login?" This is the question that decides whether "remove access within 5 minutes" is even possible. For an application without SCIM, disabling the user in the IdP stops new sign-ins, but an application session that is already open keeps working until it expires, sometimes for days. Those applications need short session lifetimes negotiated, or they are listed as a known gap.
"How long do sessions last, and must revocation be instant?" Long sessions mean fewer sign-ins and less load. Instant revocation means every application checks back with the IdP, which means more load. The answer sets both the session-store size and the token lifetime.
"Must customer data stay in a particular region?" If yes, organisations are placed in regional cells, and a user's session never leaves their region.
"Do we store passwords at all, or pass sign-in through to the customer's own directory?" Federating to the customer's Active Directory removes our password storage but makes their directory part of our availability.
Estimation
The 9 a.m. rush. The largest time-zone block holds about 40% of users, 4 million people. If 60% of them sign in within the same 30 minutes, that is 2.4 million sign-ins in 1,800 seconds: about 1,300 sign-ins a second. What that forces: see the next line; this rate is cheap for everything except one component.
Password hashing is the bottleneck, deliberately. A password hash such as Argon2id is tuned to cost about 100 ms of CPU, so that an attacker with a stolen database cannot test billions of guesses. At 1,300 sign-ins a second that is 1,300 × 0.1 = 130 CPU cores busy doing nothing but hashing. What that forces: the authentication engine is sized and autoscaled on CPU, scaled up before each region's morning, and protected by per-account rate limits so an attacker's guessing cannot consume that capacity. It is also a quiet argument for passkeys, where the server checks a signature in well under a millisecond instead of computing a deliberately slow hash.
Signing in to applications. Each user opens about 8 applications a day: 80 million SSO sign-ins a day ÷ 100,000 seconds = 800 a second, perhaps 4,000 a second in the morning peak. Each one signs a SAML assertion or an ID token, which costs well under a millisecond of CPU with an RSA-2048 or ECDSA key. What that forces: signing is cheap next to password hashing; the real constraint is the session lookup that comes with every one of those 4,000 requests.
Session storage. 10 million users × about 1.5 live sessions × about 1 KB = 15 GB per copy. What that forces: sessions fit in memory in a small Redis cluster per region, replicated across regions so a regional failure does not sign everyone out.
Leavers. About 15% of 10 million users leave each year: 1.5 million, about 4,100 a day. Each has about 20 assigned applications: 82,000 SCIM calls a day, roughly one a second. What that forces: throughput is trivial. The difficulty is reliability against 20 different third-party APIs, each sometimes down, within a 5-minute promise. That makes it a durable queue per application with retries and an alarm, not a fast path.
API
The identity provider speaks protocols that other people defined, so most of its API is not ours to design.
http
GET /.well-known/openid-configuration → issuer, endpoints, supported algorithms
GET /oauth2/jwks → the public keys applications use to check our tokens
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fcrm.example%2Fcallback&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
→ 200 OK
{ "access_token": "eyJ…", "id_token": "eyJ…", "token_type": "Bearer",
"expires_in": 3600, "refresh_token": "8xLOxBtZp8" }
→ 400 Bad Request
{ "error": "invalid_grant", "error_description": "Authorization code has expired" }http
GET /saml/app_7f2/sso?SAMLRequest=fZJdT4MwFIb…&RelayState=%2Fdashboard
→ after sign-in, an auto-submitting form that posts SAMLResponse to the application's ACS URLOur own administrative and lifecycle API:
http
POST /scim/v2/Users (called by the HR connector)
{ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "priya.sen@kk.example", "externalId": "HR-20931", "active": true }
→ 201 Created { "id": "u_19c2", … }
PATCH /scim/v2/Users/u_19c2
{ "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [ { "op": "replace", "path": "active", "value": false } ] }
→ 200 OK (deprovisioning starts here)
DELETE /admin/users/u_19c2/sessions
→ 202 Accepted { "revokedSessions": 3, "applicationsNotified": 12 }Protocol endpoints use the protocol's error format, not ours. OAuth defines {"error": "invalid_grant"} and every OAuth client library expects exactly that shape. Wrapping it in a house error envelope would break every integration. The platform's own envelope (code, message, requestId) is for the admin API only.
The token request carries a code_verifier. That is PKCE: the application proves it is the same one that started the sign-in, which stops a stolen authorisation code from being exchanged by someone else (8.4.3).
Deprovisioning is a PATCH to active: false, not a delete. Deleting the user loses the audit trail and the link to their HR record; deactivating keeps both and triggers the same downstream removal.
Data model
orgs
id UUID PRIMARY KEY
cell, region TEXT NOT NULL -- where this organisation's data lives
policy JSONB NOT NULL -- MFA rules, allowed networks, session length
users
org_id, id UUID -- PRIMARY KEY (org_id, id)
username TEXT NOT NULL -- UNIQUE (org_id, username)
external_id TEXT -- the HR system's id
status TEXT NOT NULL -- active | suspended | deprovisioned
credentials
user_id UUID, type TEXT -- password | passkey | totp
secret BYTEA -- Argon2id hash, or a passkey's public key
apps
org_id, id UUID
protocol TEXT -- saml | oidc
client_id, redirect_uris, acs_url, entity_id
scim_base_url TEXT NULL -- NULL means "no SCIM: deprovisioning is login-only"
assignments org_id, user_id, app_id -- PRIMARY KEY (org_id, user_id, app_id)
signing_keys
id, algorithm, public_jwk
private_ref TEXT -- a handle in the KMS; the key never leaves it
status TEXT -- next | active | retired
provisioning_jobs
id, org_id, app_id, user_id, op -- create | update | deactivate
attempts INT, next_attempt_at TIMESTAMPTZ, status TEXT
sessions (Redis)
session:{sid} → user, org, auth_time, methods used ("pwd", "mfa"), expires_at
user:{id}:sessions → set of sids, for "sign out everywhere"Access patterns:
| Query | Frequency | Returns |
|---|---|---|
| Find a user by organisation and username | 1,300/s peak | one row |
| Look up a session by id | 4,000/s peak | one key |
| All sessions of one user | rare (logout, leaver) | a few keys |
| An application's configuration by client id | 4,000/s peak (cached) | one row |
| Due provisioning jobs | every few seconds | up to hundreds |
Partition by org_id, grouped into cells. Every query belongs to one organisation, so an organisation and everything it owns live in one cell, and a bad deploy or overload in one cell touches only its organisations.
signing_keys.status = next exists so keys can be rotated without an outage. The next key is published in the JWKS days before it is used, so every application has already cached it when tokens start being signed with it.
apps.scim_base_url being NULL is recorded on purpose, so the 5-minute leaver promise can be reported honestly per application.
Federated Authentication Protocols
The two heavyweights in enterprise identity are SAML 2.0 (Security Assertion Markup Language) and OIDC (OpenID Connect).
SAML 2.0: The Enterprise Heavyweight
SAML is an XML-based protocol. It is older, robust, and the standard for enterprise SSO.
How SAML Works (SP-Initiated Flow)
The SAML Assertion: The core of SAML is the assertion—a signed XML document containing the user's identity (NameID) and attributes (Roles, Department). The SP trusts the assertion because it is cryptographically signed by the IdP's private key.
OIDC: The Modern Alternative
OIDC sits on top of OAuth 2.0. Instead of XML, it uses JSON Web Tokens (JWTs). It is lighter and preferred for modern web and mobile apps.
Architecture of an Identity Platform
To build an Identity Platform, we need several interacting components.
- Protocol Handlers: Translate incoming SAML AuthnRequests or OIDC Auth requests into a standard internal authentication context.
- Authentication Engine: Orchestrates the login flow (password, WebAuthn/Passkeys).
- Policy Engine: Determines if the user should be allowed in based on context (IP, device posture, time of day) and dictates if MFA is required.
- Session Manager: Tracks active sessions, handles token revocation, and global sign-out.
- Core Directory: The source of truth for users, groups, and attributes.
- Audit: High-throughput immutable logging for compliance (SOC2, HIPAA).
Deep Dive: Directory Sync and Provisioning (SCIM)
Authentication is only half the battle. If a user is deleted from the IdP, they cannot log in, but their account still exists in the SP (taking up a license).
SCIM (System for Cross-domain Identity Management) is an HTTP-based protocol for automating user provisioning.
When HR marks an employee as terminated in Workday (the HRIS), the system propagates the change:
Building the SCIM Provisioning Pipeline
Here is a simplified TypeScript implementation of a provisioning dispatcher that handles asynchronous updates to downstream systems.
typescript
// types.ts
export interface UserContext {
id: string;
email: string;
status: 'ACTIVE' | 'SUSPENDED';
groups: string[];
}
export interface AppIntegration {
appId: string;
type: 'SCIM' | 'GRAPH_API';
credentials: { baseUrl: string; token: string };
attributeMapping: Record<string, string>; // e.g. { "email": "urn:ietf:params:scim:schemas:core:2.0:User:userName" }
}
// scim-client.ts
import axios from 'axios';
export class ScimClient {
constructor(private baseUrl: string, private token: string) {}
async provisionUser(user: UserContext, mapping: Record<string, string>): Promise<void> {
// Transform internal user to SCIM payload
const scimPayload: any = {
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
active: user.status === 'ACTIVE'
};
for (const [internalAttr, scimAttr] of Object.entries(mapping)) {
if (internalAttr === 'email') {
scimPayload.userName = user.email; // SCIM often uses userName as the unique identifier
scimPayload.emails = [{ primary: true, value: user.email }];
}
// ... handle other attributes
}
try {
await axios.post(`${this.baseUrl}/Users`, scimPayload, {
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/scim+json'
}
});
} catch (error) {
if (error.response?.status === 409) {
// Conflict - user already exists. We should fall back to PUT/PATCH
await this.updateUser(user, mapping);
} else {
throw new Error(`Provisioning failed: ${error.message}`);
}
}
}
async updateUser(user: UserContext, mapping: Record<string, string>): Promise<void> {
// SCIM lookup by username, then PUT or PATCH
// ... implementation omitted for brevity
}
}
// provisioning-dispatcher.ts
export class ProvisioningDispatcher {
async handleUserStatusChange(user: UserContext, assignedApps: AppIntegration[]) {
// Fan-out updates to all downstream apps concurrently
const promises = assignedApps.map(async (app) => {
if (app.type === 'SCIM') {
const client = new ScimClient(app.credentials.baseUrl, app.credentials.token);
try {
await client.provisionUser(user, app.attributeMapping);
// Log success to audit
await this.logAudit('PROVISION_SUCCESS', user.id, app.appId);
} catch (err) {
// Push to dead-letter queue for retry
await this.logAudit('PROVISION_FAILED', user.id, app.appId, err.message);
await this.enqueueRetry(user, app);
}
}
});
await Promise.allSettled(promises);
}
private async logAudit(action: string, userId: string, targetId: string, error?: string) {
// Write to Kafka or Kinesis for immutable audit logging
console.log(`AUDIT: ${action} | User: ${userId} | Target: ${targetId} | Err: ${error}`);
}
private async enqueueRetry(user: UserContext, app: AppIntegration) {
// SQS or similar queue for delayed retry
}
}Session Management and Scaling
Identity systems are extremely read-heavy. Validating sessions and checking policies happen on almost every request in a zero-trust architecture.
Global Session Store
When an IdP authenticates a user, it issues a session cookie for the IdP domain. This allows seamless SSO into subsequent applications.
The session store must be globally distributed, highly available, and low latency. Redis (e.g., Redis Enterprise or ElastiCache with Global Datastore) is the industry standard.
High Availability and Disaster Recovery
If the IdP goes down, the entire company stops working. Availability targets are typically 99.99% or higher.
- Active-Active Deployments: Run across multiple geographic regions simultaneously.
- Cell-based Architecture: Isolate tenants into "cells." If an update breaks a cell, it only impacts a subset of customers.
- Circuit Breakers: If the Policy Engine (which might call out to a third-party risk API) slows down, fail open or fail closed depending on the security posture, but do not let it cascade and crash the Auth Engine.
Scale and failure
| What breaks | Blast radius | How you find out | What keeps it running | Recovery |
|---|---|---|---|---|
| A region goes down | its organisations, if not replicated | health checks; error rate by region | active-active cells, replicated sessions | traffic moves; sessions survive |
| Session store lost | everyone signed out at once | session-miss rate jumps | replication; capacity for a re-login storm | the 130-core hashing budget is what absorbs it |
| Signing key compromised | every token it signed | KMS alerts; security report | the next key already published | switch to it; retire the old key; revoke sessions |
| An application's SCIM API down | leavers keep that app | provisioning job age past 5 minutes | durable per-app queue with retries | jobs drain when it returns; alert security meanwhile |
| HR sends a mass termination by mistake | thousands locked out | deprovision rate far above normal | a brake: hold if over ~1% of an org in an hour | a human releases or discards the batch |
| Clock skew between servers | SAML assertions rejected | "not yet valid" error rate | a short allowed skew window | fix time sync |
The mass-termination brake is the one people forget. An HR import bug that marks every employee as terminated is a real event, and an identity provider that obeys instantly turns one bad file into a company-wide outage. Deprovisioning should be fast for one person and deliberately slow for ten thousand.
What the interviewer will push on
- Clock Skew in SAML: "How do you handle a SAML assertion that the SP rejects because it says it was issued in the future?"
- Answer: Time sync is critical. Implement a
NotBeforeandNotOnOrAfterwindow with a grace period (e.g., +/- 3 minutes) when generating or validating assertions to account for clock skew between the IdP and SP servers.
- Answer: Time sync is critical. Implement a
- IdP-Initiated vs SP-Initiated SAML: "Which is more secure and why?"
- Answer: SP-initiated is generally more secure. In IdP-initiated flows, the SP receives an unsolicited SAML Response. This is vulnerable to CSRF-like attacks (stolen assertions being replayed) because the SP didn't generate a request ID (
InResponseTo) to map the response back to a specific browser session state.
- Answer: SP-initiated is generally more secure. In IdP-initiated flows, the SP receives an unsolicited SAML Response. This is vulnerable to CSRF-like attacks (stolen assertions being replayed) because the SP didn't generate a request ID (
- SCIM Failure Handling: "Workday sends a webhook to terminate an employee. The SCIM call to AWS IAM fails. What happens?"
- Answer: The system must not drop the event. It should be written to a persistent message queue (like Kafka or SQS) before acknowledging the webhook. If the SCIM call fails, it goes into a retry loop with exponential backoff. The audit log must record the failure so security teams can intervene manually if needed.
- Handling Massive Spikes (The 9 AM Login Rush): "How does the system handle everyone logging in at the exact same time?"
- Answer: Cache heavily. Read paths (user lookups, app configurations) should hit read replicas or distributed caches (Memcached/Redis). The password hashing algorithm (like Argon2 or bcrypt) is CPU intensive by design; auto-scaling groups for the Auth Engine must scale on CPU utilization well ahead of the spike.
Recall
- SAML 2.0: XML-based, signed assertions, heavy rely on back-and-forth browser redirects.
- OIDC: JSON Web Tokens (JWT), uses OAuth 2.0 underlying mechanisms, lighter weight.
- SCIM: JSON REST API standard for provisioning/deprovisioning users and groups across systems.
- Session Store: Must be distributed and ultra-fast (Redis). A failure here logs everyone out.
- Resiliency: Identity platforms require cell-based, active-active architectures due to their status as a single point of failure for all corporate tools.
Quiz
- Why is a standard like SCIM necessary alongside SAML/OIDC?
- In a SAML SP-Initiated flow, how does the SP know the SAML assertion is actually from the trusted IdP?
- Why is an IdP-Initiated SAML flow susceptible to CSRF attacks?
- When handling a 9 AM login spike, which component of the IdP architecture is most likely to bottleneck on CPU, and why?