The whole design, in writing
Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.
The big idea
Why identity blocks the deal
Nobody has ever lost an enterprise deployment because the model scored two points lower on an eval. They lose it because a security reviewer asked “what happens to access when someone leaves?” and the honest answer was “you email us”.
Build the identity layer properly: federate per tenant, key users on something immutable, provision over SCIM with deactivation handled correctly, map groups to roles through a layer their admin controls, revoke inside the promised window, and log everything a reviewer will ask for.
Step 1 · The skeleton
Let the right people in
The naive version: your own username and password, per user, in your own database. Every enterprise buyer refuses this within one meeting — it means their leavers keep working accounts, their password policy does not apply, and their multi-factor requirement is unenforceable.
Stand up an Auth Service that issues a session after the customer’s own identity system vouches for the user. You are a relying party, never a source of truth. Everything that follows makes that relationship multi-tenant, correct and revocable.
Why this piece earns its place
Worth naming what this skeleton quietly assumes: that there is always a working identity provider on the other side. Two days break it. The first is day zero — before any trust is configured, somebody has to sign in and configure it, and that somebody cannot be federated yet. The second is the morning their provider is down, when federation is the only way in and nobody, including their own administrators, can reach the screen that would fix it. So design the local path rather than discovering it: a small number of named break-glass accounts, hardware-backed second factor, no ordinary work possible while signed in that way, and every use loud — alerted at the time, not found later. The temptation is to leave it undesigned and let someone quietly create a local admin at two in the morning when it is needed. That account then never goes away, it lives outside the customer’s directory, and it is the first thing a reviewer finds. Build it deliberately, keep it narrow, and be the one who raises it.
What the new pieces do
- Userclient
- Signs in with their work account and expects never to see a password prompt from you. They belong to the customer’s directory, not to yours — which is the fact that shapes the entire design.
- Auth Servicebackend
- Resolves which tenant a sign-in belongs to, then validates the assertion or token against that tenant’s configuration. One deployment, many trust relationships, no shared state between them.
Step 2 · Federate
SAML or OIDC — and the customer decides
One deployment, many customers, each with a different identity provider, a different protocol preference and a different set of attribute names. Hard-coding one of them means the next customer is a fork.
A large regulated customer insists on SAML 2.0. OIDC is technically better. What do you do?
All true, and irrelevant. Their federation service and their security team decided years ago. Arguing spends goodwill you need for the questions where you can actually change the outcome.
Protocol is tenant configuration, not an architectural commitment. Large regulated estates hand you SAML with no alternative, and being able to say yes immediately is worth more than the technical argument.
That closes the door on newer estates, mobile flows and machine-to-machine cases, and it commits you to XML signature validation as your only path — which is the easiest thing in this whole design to get subtly wrong.
Make protocol a per-tenant configuration: issuer, certificate, protocol, attribute mapping, all resolved at sign-in. Validate the assertion’s own signature, audience, recipient and time bounds — and pin the certificate with its expiry in a calendar.
Why this piece earns its place
The part this glosses over is how you know which tenant a sign-in belongs to before you know who the user is. The obvious answer is the email domain they typed, and it holds until two tenants claim the same domain — an acquisition, a subsidiary, a contractor carrying an address at the parent company — or until someone types a domain belonging to a tenant they do not work for and you hand their browser a redirect to that tenant’s provider. Make resolution structural instead: every tenant gets its own sign-in endpoint, so the connection is in the URL and never inferred, with domain hints as a convenience layered on top. Verify domain ownership before you bind one at all. The same problem arrives from the other direction with provider-initiated SAML, where an assertion lands unsolicited and there is no request of yours to match it against — the tenant has to come from which endpoint received it, and you still check that the issuer inside the assertion is the one that endpoint is configured to trust. Otherwise the message is choosing its own trust anchor.
- per tenantprotocol + config
- sign the assertionnot just the wrapper
- pin the certand calendar it
What the new pieces do
- Identity Providerclient
- The customer’s own identity system. It decides who exists, who they are, and — through SCIM — when they stop being an employee. You are a relying party, never the source of truth.
- SAML / OIDCservice
- Signature on the assertion (not just the response wrapper), audience, recipient, time bounds and in-response-to. Certificate pinned, and its expiry in a calendar — rotation is the classic 12-to-24-month outage.
Back of the envelope
- support both, lead with OIDC
- the customer decides
- validate assertion signature
- audience, recipient, time bounds
- reject unsigned assertions
- no fall-back branch
- certificate expiry tracked
- the classic 12–24 month outage
Step 3 · Key it correctly
The identifier that does not change
You keyed users on email, and it worked perfectly until the customer was acquired and every address changed on one weekend. Now every user is a stranger, their history is orphaned, and the fix is a data-repair project nobody budgeted for.
Store the User Record under the provider’s immutable subject identifier — oid in Entra, a persistent-format NameID in SAML — and treat email as a display attribute that is allowed to change.
Why this piece earns its place
The assumption underneath this is that the subject identifier outlives the relationship, and it outlives everything except one event: the customer changing provider. Migrate a directory from one vendor to another and every subject identifier is reissued — the key chosen because it never changes has just changed for every user at once, and the system correctly treats an entire company as strangers. That is not an argument for keying on email. It is an argument for having a re-link path designed before the day you need it. Treat it as a one-time supervised migration: propose pairings between old records and new sign-ins using verified email, show the customer’s administrator the proposed list, have them approve it, and record who approved each link. What must never exist is a silent fall-back that matches on email whenever a subject identifier looks unfamiliar, because that is an account-takeover primitive — anyone who can set a display address at a provider you trust inherits somebody else’s account. Key by tenant and issuer alongside the subject too, since these identifiers are unique within a provider, not across the world.
- immutable subject idthe key
- emaildisplay only
- retrofit costa data migration
What the new pieces do
- User Recordservice
- Keyed on the provider’s immutable subject identifier, with email as a display attribute. This one decision is what survives a marriage, a rebrand and an acquisition without a data-repair project.
Back of the envelope
- keyed on immutable subject identifier
- oid / persistent NameID
- email is a mutable attribute
- not an identity
- attribute mapping explicit per tenant
- names differ everywhere
Step 4 · The question that decides
SCIM, and the PATCH nobody implements
Single sign-on answers who is this. It does not answer should this person still have an account. A user removed from the directory cannot sign in — and their account, their API tokens and their scheduled exports all still exist.
You implemented SCIM DELETE. The customer offboards someone. What happens?
Only if their identity provider calls DELETE — and most do not. Entra and Okta typically deactivate rather than delete, precisely so that history and audit trails survive the offboarding.
There is no reconciliation sweep in this design, so nothing is delayed. Nothing happens at all, and no error is raised.
The integration reports success, the dashboard stays green, and the departed employee keeps their access. It is the most common enterprise SSO defect there is, and it is exactly what gets tested.
Implement the SCIM Endpoint properly: idempotent creates, filtered lookups, pagination, and PATCH active=false as the primary deactivation path. If you cannot ship SCIM in time, offer a documented daily reconciliation as a compensating control with a date — reviewers accept that far more often than people expect.
Why this piece earns its place
Deactivation has to be a state change and never a delete, which has a consequence worth saying out loud: the record survives. Their content, their ownership of shared objects, their approvals and their name on things all stay, because a reviewer asking who signed this off last year needs an answer — and a cascading delete destroys the evidence at precisely the moment offboarding was supposed to produce it. It also has to be reversible. Contractors come back, people leave and return, and a provider expresses that as PATCH active=true on the same subject identifier with no other ceremony, expecting the account they had. So reactivation restores the record and re-resolves their group mappings — and restores nothing that was revoked. Old sessions and issued tokens stay dead; they get new ones by signing in. Implementing deactivation as a deletion breaks both halves at once: the history you owed a reviewer is gone, and the return trip becomes a blank new account, which the customer reads as your integration having lost their data.
- PATCH active=falsethe real path
- idempotentproviders retry
- paginatetenants have thousands
What the new pieces do
- SCIM Endpointservice
- Idempotent, paginated, and correct about the one operation that matters: deactivation arrives as PATCH active=false far more often than as DELETE.
Back of the envelope
- PATCH active=false handled first
- DELETE is the rare case
- idempotent creates
- IdPs replay operations
- 409 on duplicate userName
- correct status codes matter
- every operation logged
- this is audit evidence
Step 5 · Survive the reorg
Groups map to roles through a layer they control
You mapped their directory groups straight to permissions. It works for a month. Then a reorg renames FIN-AP-ANALYSTS, forty people lose access on a Monday, and you are paged for a change nobody told you about.
Put a Group → Role table in between, editable by their admin in a screen. Their groups map to your small fixed role set — three is usually enough at go-live — which maps to permissions. Keep data scoping on a separate axis.
Why this piece earns its place
The question this table raises on its first day of real use is what happens when someone matches three mapped groups at once, which is normal — directories nest, and everybody is in an all-employees group. Taking the union is the default that feels safe and is not: a well-meaning administrator maps that broad group once so nobody is stranded, and from then on every person at the company carries whatever it granted, on top of everything else they match. Pick a resolution rule — highest wins, or most specific wins — and put it in the screen rather than in the documentation. The one thing that makes this table safe to hand over is a preview: the administrator types a real person’s name and sees the role they would end up with and which mapping produced it, before saving anything. The other pressure is on the role list itself. Every customer asks for one more role; each is easy to add and nearly impossible to withdraw later, because you cannot see who has come to depend on it. A list nobody can explain is how the mapping stops being reviewed at all.
- 3 rolesat go-live
- their admin editsnot you
- unmapped→ deny, loudly
What the new pieces do
- Group → Roledata
- Their directory groups map to your small fixed role set through a table their own admin edits. Data scoping — "approver for EMEA only" — stays on a separate axis, or the role list explodes.
Back of the envelope
- customer-editable mapping table
- every change logged and attributed
- small fixed role set
- roles are your product, groups are theirs
- data scoping on a separate axis
- or the role list explodes
- unmapped user denied with a message
- never a silent default
Step 6 · Make revocation true
Sessions and tokens die with the account
Deactivation blocks new sign-ins. The user’s current session is still valid, their API token still works, and their nightly export still runs. You committed to revoking access within four hours and you have revoked nothing.
Propagate deactivation to Sessions and Tokens: short lifetimes plus a revocation check, so access ends inside the promised window rather than whenever the user next signs in — which may be never.
Why this piece earns its place
The knob here is token lifetime, and it is set against the revocation check rather than on its own. Long lifetimes put that check on the hot path of every request, turning a security control into an availability question: when the revocation store is unreachable, failing open re-opens the hole you just closed, and failing closed takes the whole product down because one store blipped. Very short lifetimes avoid that and multiply refresh traffic against your auth service — and against the customer’s provider, if refresh re-validates there. What makes it tractable is the shape of the data. You never need to ask is this user still valid on every request; you need the set of subjects revoked recently, and recently is bounded by the longest token lifetime, so that set stays small enough to replicate everywhere and hold in memory. Failing closed is then cheap, because the thing you depend on is tiny. And the window you promised is not the token lifetime alone — it is that plus however long a revocation takes to reach every place that checks. Measure the second number before writing the first into a contract.
- sessionsrevoked
- API tokensrevoked
- windowyou promised it
What the new pieces do
- Sessions + Tokenscache
- Short-lived tokens with a revocation check, so deactivation ends access inside the window you promised rather than at the user’s next sign-in — which may never come.
Back of the envelope
- deactivation revokes sessions and tokens
- not just new logins
- short token lifetimes
- bound the worst case
- scheduled jobs re-check identity
- exports outlive employment
Step 7 · Prove it
The evidence a reviewer asks for
The security review asks for a joiner–mover–leaver walkthrough, an audit log sample, and a break-glass procedure. You have all three behaviours and none of them written down, so the review takes three more weeks.
Log to an immutable Audit Log: every authentication, permission change, provisioning event and break-glass use — retained to their policy and exportable to their SIEM. Then package the artefacts: an identity architecture diagram, the JML walkthrough, ten real redacted log lines, the break-glass procedure, and a named gap list with dates.
Why this piece earns its place
The cheap version of this is pointing the reviewer at your application logs, and it loses for a reason that has nothing to do with volume. Those logs are mutable, rotated on a schedule chosen for operations rather than evidence, and written as sentences — a line saying a role was updated, with no field for who did it, in which tenant, or what the value was before. The first real question, show me every permission change on this user over the past year, then becomes a grep with a human reading the results, which is not evidence and does not survive being asked a second time. Audit events are structured records written in the same transaction as the change they describe, carrying actor, subject, tenant, before and after as fields, into an append-only stream with its own retention. The export is the other half, and it is an authorisation problem rather than a logging one: a customer pulling events into their own tooling must receive theirs and only theirs, from a path scoped per tenant and tested as carefully as sign-in. A cross-tenant leak in the audit export is the worst possible place to have one.
- 10 real linesbeat any assurance
- break-glassthey always ask
- gap listvolunteered
What the new pieces do
- Audit Logdata
- Every authentication, permission change, provisioning event and break-glass use — immutable, retained to their policy, exportable to their SIEM. Ten real lines end a review that assurances do not.
Back of the envelope
- auth, permission, provisioning, break-glass
- all logged immutably
- exportable to their SIEM
- their tooling, not yours
- JML walkthrough written
- the leaver paragraph is the one they read
- named gaps with dates
- volunteered, not discovered
You did it
You just designed enterprise SSO & provisioning.
Everything you assembled, in order
- You are a relying party — the customer’s directory decides who exists and who has left.
- Protocol is per-tenant configuration: support both, lead with OIDC, expect SAML.
- Key users on the immutable subject identifier; email is a display attribute.
- Deactivation arrives as PATCH active=false — a DELETE-only SCIM implementation fails silently.
- Map their groups to your small fixed roles through a table their own admin edits.
- Revocation must reach live sessions and API tokens inside the window you promised.
- Package the evidence: diagram, JML walkthrough, real log lines, break-glass, and a volunteered gap list.