SCIM Deprovisioning Is a Promise Your App Probably Breaks
SCIM tells you a user is gone, but sessions, refresh tokens, and API keys often outlive deprovisioning. Here's why offboarding needs more than a user row.
When someone gets fired, the security team assumes access dies with the ticket. HR flips the switch in the identity provider, SCIM propagates the change to every connected app, and the former employee is locked out everywhere. That's the story SCIM tells. In a lot of apps, it isn't true.
SCIM will faithfully deliver the message that a user is gone. What it can't do is guarantee your app acts on that message the way an offboarding runbook assumes. The gap between "SCIM said the user is inactive" and "the user can no longer do anything" is where terminated employees keep their access — sometimes for hours, sometimes until a token naturally expires.
What SCIM actually promises
SCIM 2.0 is defined by two IETF documents: RFC 7643, the core schema, and RFC 7644, the protocol. Both were published as Standards Track documents in September 2015. The schema describes users and groups as JSON resources; the protocol defines how an identity provider and an application exchange those resources over HTTP.
The protocol is deliberately small. It supports creating, modifying, retrieving, and discovering identity resources using a subset of HTTP methods: GET to read, POST to create or search, PUT to replace attributes, PATCH for partial updates, and DELETE to remove a resource. That's the whole surface area. SCIM moves identity state between two systems. It says nothing about what happens to the sessions, tokens, and keys that state implies.

"Deprovisioned" usually means one boolean flipped
Here's the first surprise for anyone who assumes deprovisioning means deletion. Most identity providers don't delete a user when someone is offboarded. They set the SCIM active attribute to false. In the core schema, active is a boolean describing the user's administrative status — nothing more. The DELETE method exists and does remove a resource, but IdPs generally reach for a PATCH that flips active instead.
There's a good reason for this. Soft-deactivation preserves the user record for audit trails, re-hires, and referential integrity. A deleted user breaks every foreign key that pointed at it. So the IdP does the responsible thing and leaves the row in place with one field changed.
The consequence is that your app receives a small update — active: false — and it's entirely up to your code to decide what that means. If your deprovisioning handler updates the user row and stops there, you've recorded that the user is inactive. You haven't actually revoked anything.
The polling window
The timing makes it worse. Not every SCIM integration is push-based. Plenty of provisioning runs on a schedule: the app polls the IdP, or the IdP polls a source of truth, on an interval measured in minutes or hours. Polling-based sync creates a window where a terminated employee still shows as active in your app because the next sync hasn't run yet.
During that window, nothing has changed from your app's perspective. The user is active, their session is valid, their tokens are good. The termination has happened in the real world and in the IdP, but your app doesn't know yet. If someone walks out angry at 9:02 and your sync runs at the top of every hour, they have most of an hour of fully legitimate access to do damage.

Push-based SCIM shrinks this window but doesn't close it. There's still latency between the IdP sending the PATCH and your app finishing whatever it does in response — and if that response is just a database write, the window never really closes at all, because the write doesn't touch the things that actually grant access.
Sessions and tokens outlive the user row
This is the part that quietly breaks the promise. Flipping active to false changes a field in your database. It does not reach into the sessions and tokens you've already handed out.
Any session cookie or refresh token you issued before the deprovisioning event keeps working until your app explicitly revokes it. Access is granted by the credential in the user's browser or scripts, not by re-reading the user row on every request. A long-lived refresh token can mint fresh access tokens for as long as it's valid. An active session sails right past a active: false flag your login path never re-checks. SCIM has no opinion here — the protocol depends on TLS and standard HTTP auth schemes like OAuth 2.0 bearer tokens, and how those tokens are validated and revoked is left entirely to the token-issuing system.
API keys are the worst offender because they usually aren't tied to the SCIM user at all. A personal access token a departing engineer created lives in your secrets table with its own lifecycle. Deprovisioning the user does nothing to it unless you've explicitly wired the two together. That key keeps pulling data or pushing deploys long after the person is gone.
What real offboarding requires
Treating SCIM as offboarding is the mistake. SCIM is the signal. Offboarding is what your app does when it receives the signal. Real deprovisioning means killing the sessions, tokens, and API keys tied to a user — not just updating the user row.

When your SCIM handler sees active: false, treat it as a trigger, not an update. Concretely:
- Revoke sessions. Invalidate every active session for that user server-side, so the next request with an existing cookie is rejected instead of trusted.
- Kill refresh tokens. Mark the user's refresh and long-lived tokens as revoked so they can't mint new access tokens.
- Disable API keys. Find every key, token, and service credential the user created or owns and turn them off.
- Shorten the blind spot. Prefer push-based SCIM over polling, and if you must poll, poll often enough that the window is minutes, not hours.
None of this is exotic. It's the difference between recording that someone left and making sure they actually can't get back in. SCIM did its job the moment it delivered active: false. Whether that boolean means anything is on you.
If your deprovisioning path ends at an UPDATE users SET active = false, the promise is already broken. The user row is the easy part. The access lives everywhere else.