SCIM directory sync: A deep dive into deprovisioning
What actually happens when a user is removed: the event your app receives, the directories that never send one, and how to catch what gets dropped.
A missed deprovisioning event means a former employee still has access to your customer's data inside your application, and nobody finds out until someone goes looking. That asymmetry is the whole story of directory sync. Provisioning is forgiving: if it is slow or duplicated, the worst case is a confused new hire. Deprovisioning is the direction that gets audited.
It is also harder than the protocol makes it look. A termination rarely arrives as a delete. PATCH bodies are not portable between identity providers. And the system that actually knows someone was terminated is frequently the one that does not speak SCIM at all.
This post covers those three problems and what to do about each: how deactivation really reaches your app, what a normalized event looks like once you stop taking the phrase on faith, the reconciliation rules that keep a dropped event from becoming a live account, and how to see what a customer's identity provider actually sent when they insist they deprovisioned someone.
If you want the protocol basics first, the SCIM guide covers what the standard is, how the User and Group resources are shaped, and why SCIM 2.0 is the version to build against.
The IdP says delete and means deactivate
The first surprise is that a termination almost never arrives as a DELETE. Most identity providers send a PATCH that flips a boolean:
Soft delete is the right default. Retention policies, audit obligations, the chance the person comes back, and all the records in your app that reference the account are reasons not to destroy anything. But it puts the burden on you to interpret it correctly. Setting active to false has to end the session, not grey out a row in an admin list. That is the behavior the security questionnaire is asking about, and the difference between the two is invisible until someone tests it.
The second surprise is that PATCH bodies are not portable. The operation above is the simple case. Group membership changes arrive as operations against a filtered path:
Okta, Entra ID and the rest each have their own preferences about whether a membership removal is a remove with a filter, a replace of the whole members array, or an update on the user side. All are defensible readings of RFC 7644. Writing a parser that handles every shape correctly, and keeps handling it as providers change, is most of why building SCIM is hard.
The directories that never speak SCIM
The larger problem is that SCIM only covers the customers whose systems speak it, and the exceptions are concentrated in your biggest accounts.
- Google Workspace has a rich Directory API but does not push SCIM events at you. For changes that originate in Google, you pull, or you subscribe to push notifications on specific resources.
- On-prem Active Directory and LDAP frequently have no webhook surface at all, and sometimes no outbound internet route to reach you even if they did.
- HRIS platforms like Workday, BambooHR and HiBob are the real source of truth for employment status, because they own who is hired, terminated or on leave. Each exposes a different API shape, and some offer nothing but a scheduled SFTP drop of a CSV.
That last one matters more than its share of the market suggests. If a customer's authoritative system for terminations is Workday and you only listen for SCIM events from their IdP, you will miss the event that matters most, because the IdP change is downstream of the HR change and may lag it by a day or never happen at all.
What a normalized event actually looks like
This is where the abstraction earns its keep, and it is worth looking at the payload rather than taking "normalized event" on faith.
WorkOS Directory Sync emits ten event types: dsync.activated and dsync.deleted at the directory level, dsync.user.created, dsync.user.updated and dsync.user.deleted for people, and dsync.group.created, dsync.group.updated, dsync.group.deleted, dsync.group.user_added and dsync.group.user_removed for groups.
Note what is not in that list: there is no deactivation event. Deprovisioning arrives as dsync.user.deleted, and the interesting part is the state field:
Per the docs, state "indicates directory user state at time of deletion." So the same event type carries two different stories. A user removed while already deactivated arrives with state: "inactive". A user who was active right up until they were unassigned from your app arrives with state: "active". Both revoke access. They are not the same thing in an audit log, and recording which one you got costs you a column and buys you an answer when someone asks.
That field is also the soft-delete distinction from earlier, preserved rather than flattened. The IdP said delete and meant deactivate; the event tells you both halves.
Three more things in that payload deserve attention.
-
idp_idis your stable key. It is the identifier the source system guarantees, and it is what you should reconcile on. Never match on email or display name. People change both, and reconciling on a mutable key means you eventually deprovision the wrong person or fail to deprovision the right one. roleandrolescome through the same event. Role mapping from IdP groups is not a separate integration; it rides along.- Groups are deliberately absent from user events. The docs are explicit that the
groupsfield is omitted from user payloads "to avoid performance issues in large directories." So if your access model derives from group membership, you cannot maintain it from user events alone. You have to handledsync.group.user_addedanddsync.group.user_removedas first-class, and a handler that only listens to user events will drift silently on exactly the customers who are big enough to care.
A handler that takes all of this seriously is not long:
Notice what is not in that handler. No leniently parsing active because some Entra connectors send the string "False" rather than a boolean. No case-insensitive comparison because the operation sometimes arrives as Replace. No normalizing fully qualified attribute URNs out of a PATCH path. No returning the full resource body on a PATCH because Entra flags the sync as failed if you answer with a bare 204, and no filter-syntax subset to work around.
Those traps are all real, and they are the ones a team that terminates SCIM itself spends its time on. On this path you do not terminate SCIM, so they are somebody else's problem. What is left is the part that is genuinely yours: deciding what removal means inside your product.
How fast is access actually removed
That question is the one the security questionnaire is really asking, and the honest answer is a chain of latencies, most of which you do not control.
The identity provider's own cadence dominates. Okta pushes changes as they happen and acts on them near-immediately. Microsoft Entra ID runs provisioning on a fixed background cycle of roughly 40 minutes, which is not configurable, though an admin can force a single user through with on-demand provisioning in well under a minute. An HRIS that exports nightly is a night. If a customer asks how quickly a termination reaches you, the truthful answer starts with which system they are terminating in.
Then your handler runs, and this is the part you own entirely. Flipping a database flag blocks new logins and nothing else. Deprovisioning is not finished until five things happen:
- Server-side sessions for that user are deleted, so the next request forces re-authentication
- OAuth access and refresh tokens the user holds are revoked
- Unexpired JWTs are handled, either through a denylist keyed by user or by keeping access-token lifetimes short enough that the gap is acceptable
- API keys and personal access tokens the user created are killed, because those outlive every session and are how a departed engineer keeps access for months
- Any cached authorization decision for that user is busted
The JWT case is the one that quietly sets your floor. Stateless tokens with a one-hour lifetime and no revocation check mean a user deprovisioned at 10:00 keeps working until 11:00, no matter how fast the event reached you. If you are going to quote a number to a customer's security team, that number has to include this.

When someone comes back
Reactivation is the same problem read backwards, and it breaks in a specific way.
A previously removed user who is reassigned to your app arrives either as a fresh dsync.user.created or as an update flipping them back to active. Match on idp_id and flip the existing record. If you match on email, you mint a second account, the person loses their history, and now two rows claim the same human.
Make the create path idempotent for the same reason. Seeing an idp_id you already have should reactivate and return, not fail. A create that errors on a returning user is a create the provider will retry forever.
Reconciliation rules that hold up
Once you are past pure SCIM, deprovisioning stops being event handling and becomes reconciliation. Four rules separate the implementations that hold from the ones that quietly leak access.
- Treat the delta stream as an optimization, never as the guarantee. Providers offer change feeds, Google's
syncTokenand Microsoft Graph's@odata.deltaLinkamong them, and those streams drop events often enough that you cannot build correctness on them. The pattern that works is a full reconciliation on a schedule as the correctness guarantee, with the delta stream layered on top purely to cut latency. - Make every path idempotent. The same change can reach you three ways: a webhook, a delta poll and the nightly snapshot. Processing the same update three times has to produce an identical end state. This is where
previous_attributesondsync.user.updatedis useful, because it tells you what actually changed rather than making you diff the whole record, but the handler still has to be safe when it sees the same event twice. - Decide explicitly which source wins. A real enterprise customer has an HRIS that owns employment status, an IdP that owns authentication and group membership, and an IT system that owns email and provisioning state. These disagree constantly. Someone terminated in Workday on Friday can still look active in the IdP on Monday. You need a per-customer priority model naming which system is authoritative for which attribute. Silent last-write-wins is how a terminated employee keeps SSO access.
- Never hard delete. Mark the user inactive with a reason and a timestamp. You need the row to answer the audit question, and you need the reason to distinguish "left the company" from "unassigned from this app" from "we dropped an event and caught it in reconciliation."
What to write down for the auditor
The reason all of this gets scrutinized is that SOC 2 auditors test access removal by sampling terminated employees and asking for evidence. Not a policy document, evidence: this person, this date, access gone.
So the record you write on every deprovision is the deliverable. It should carry who was removed, when, which system triggered it, and what was actually revoked:
Two things make that record useful rather than decorative. It counts what was revoked, so "we deactivated them" becomes "we ended three sessions and killed an API key." And it records a partial failure as a partial failure, because a logged failure you retried is defensible where a silent gap is a finding.
Keep them append-only and queryable by user and date range, so an auditor's sample is a filter rather than a data-export ticket. Audit Logs is built for exactly this shape, and it streams to the customer's own SIEM, which turns your evidence into their evidence.
When it goes wrong, read what the IdP actually sent
Every SCIM integration eventually produces the same support thread: the customer's IT admin says they deprovisioned someone, your app says it never heard about it, and neither side can see the other's evidence.
As of September 4 2026, the WorkOS Dashboard surfaces SCIM request logs per directory. Every SCIM write the identity provider sends is recorded with the full request and response payloads plus filterable identifying attributes, retained for 30 days with sensitive values redacted. You find them in the directory details for an organization.
That closes the loop on two things this post has been circling. It answers "did the IdP actually send it" without a support ticket. And it is the fastest way to see the concrete PATCH shape a given provider uses, which beats reading RFC 7644 and guessing which of the three defensible interpretations your customer's Okta tenant chose.
What this buys you
Building all of it yourself is a permanent surface, not a project: a connector per provider, polling for the ones that will not push, schema normalization, conflict resolution, idempotent reconciliation, and a debugging story for when a customer says it did not work.
WorkOS Directory Sync handles the provider layer, including Google Workspace's pull model, Workday and BambooHR through their HRIS APIs, SCIM providers like Okta, Entra ID and JumpCloud, and SFTP for the customers who hand you a CSV. What reaches your application is one event model regardless of what is on the other side, so you write the deprovisioning path once.
What stays yours is the part that should: deciding what revoked access means in your product, which source of truth wins for which attribute, and what you record so that when an auditor asks whether a departed employee lost access on their last day, the answer is a row with a timestamp rather than a shrug.
The pricing is worth knowing before you scope the build, because it changes the arithmetic. Directory Sync is billed per connection per month, at $125 each for your first 15 connections, then $100 each from 16 to 30, $80 from 31 to 50, and $65 from 51 to 100. A connection costs the same whether the customer behind it runs Okta, a nightly Workday export, or an SFTP drop, and the same whether they have twelve employees or twelve thousand. One directory, one line item, and the cost tracks how many enterprise customers you have signed rather than how large they got.