---
key: "scim-provisioning-deprovisioning"
title: "SCIM deprovisioning: make account disablement remove every usable authority"
url: "https://softwaresecurity.com/domains/identity-crypto/authentication/scim-provisioning-deprovisioning/"
api: "https://softwaresecurity.com/api/v1/articles/scim-provisioning-deprovisioning.json"
domain: "identity-crypto"
topic: "Identity \u0026 cryptography"
type: "Guide"
identifiers: ["CWE-613","CWE-863","A01:2025","A07:2025","V7.4.2","V8.3.2","V8.4.1"]
tags: ["SAML, SCIM and enterprise SSO"]
stages: ["build"]
roles: ["developer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# SCIM deprovisioning: make account disablement remove every usable authority

Connect SCIM resource updates to current sessions, tokens, group-derived permissions and background jobs, with explicit tenant mapping, CAEP revocation events and a measurable revocation contract.

Canonical URL: https://softwaresecurity.com/domains/identity-crypto/authentication/scim-provisioning-deprovisioning/

## In short

Resolve the tenant from the authenticated SCIM connector, map users by the provider’s immutable ID, and commit a disable as one transaction that marks the user inactive, bumps an authorization epoch, removes local grants, revokes sessions and tokens, and cancels pending jobs. Check the epoch on every request and job, accept CAEP session-revoked events from your identity provider, revoke tokens you hold elsewhere with RFC 7009, and measure how long each enforcement point takes.

- A successful SCIM `PATCH` proves the resource changed, not that sessions, tokens and queued jobs stopped working.
- Choose the tenant from the connector credential, then map users by the immutable SCIM `id`, never by email.
- Commit `active=false`, a new epoch, removed grants, revoked credentials and cancelled jobs in one transaction.
- Workers check current authority when a job runs, not the authority captured when it was queued.
- Shared Signals and CAEP 1.0 (final, August 2025) carry session-revoked events between services as signed SETs.
- Re-enabling a user issues new credentials; it never revives old sessions, tokens or jobs.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Media type | `application/scim+json` | — | RFC 7644 section 3.1 |
| Partial updates | `PATCH` with `urn:ietf:params:scim:api:messages:2.0:PatchOp`, applied atomically | — | RFC 7644 section 3.5.2 |
| Concurrency | `ETag` and `If-Match` where `ServiceProviderConfig` reports `etag` support | Treat versions as opaque; never compare them numerically. | RFC 7644 section 3.14 |
| Revocation events | CAEP `session-revoked` and `credential-change` over SSF push delivery | Push uses RFC 8935; poll delivery (RFC 8936) also exists. | OpenID SSF 1.0 and CAEP 1.0 |
| Event token checks | Signature, `typ` of `secevent+jwt`, issuer, audience and a unique `jti` | SSF SETs carry the subject in a top-level `sub_id` claim and one event each. | OpenID SSF 1.0 section 4 |

## Verify it

1. Keep a session, a personal token and a queued job alive, disable the user through SCIM, and confirm all three fail on their next use.
2. Restart the application after the disable and repeat the checks: none of the old credentials may come back.
3. Disable a user in one tenant while another tenant has the same email and `externalId`, and confirm only the first changes.
4. Send a signed CAEP session-revoked event twice and confirm sessions are revoked once and the second delivery is acknowledged.
5. Measure the time from the directory disable to denial at each enforcement point and alert when it exceeds your target.

## What you will learn

- Keep provisioning identifiers scoped to the authenticated tenant.
- Translate disable and membership changes into enforceable authority changes.
- Test retries, stale updates, re-enable and incomplete remote revocation.

Audience: SaaS developers and enterprise identity engineers integrating provisioning with application authorization.

Before you start:

- Read a JSON HTTP request and a SQL transaction.
- Understand sessions, bearer credentials and group-derived permissions.

Time: About 31 minutes: 7 to read and 24 for the exercise

## Treat provisioning as a separate security boundary

SSO answers how a login assertion is accepted; provisioning manages the resource lifecycle. An employee leaving a directory must lose usable application authority even if they never attempt another login. A connector can correctly update a User resource while a cached session, API credential or queued export keeps working. Test those paths directly.

Draw the authority inventory before implementing the connector: browser sessions, refresh grants, personal tokens, resource grants, role caches, delegated jobs and service-owned credentials. Label which are user-bound and which are independently owned service identities. Do not destroy a shared service identity merely because its creator leaves; require an accountable ownership transfer instead.

## Choose the tenant from the authenticated connector

SCIM `id` is assigned by the service provider; `externalId` is supplied by the provisioning client and scoped to its provisioning domain. The User `active` attribute describes administrative status; its concrete effect is defined by the provider. `Users.groups` is read-only; membership changes belong in the relevant Group resource’s `members` attribute.

For this application, a connector credential selects Acme before resource lookup. Map that connector and the stable external user identity to the provider’s immutable user ID. Email/userName changes must not transfer another person’s resources or select a different tenant. Two organizations may legitimately use the same email or `externalId`. Store and test their mappings independently.

Scope connector permissions to the enrolled tenant and required lifecycle operations. Protect and rotate its secret, bound directory queries and returned attributes, and record its actor identity for changes. Reject requests attempting to override the tenant in request content. Connector authentication should not reuse an ordinary end-user login session.

## Implement SCIM operations with their own semantics

SCIM `PATCH` uses `application/scim+json` and the `PatchOp` schema. Apply its `Operations` in order and atomically for the target resource; a failed operation must not leave a partial resource update. Discover supported features through `ServiceProviderConfig`. When versioning is supported, use the resource `ETag` and `If-Match` to detect concurrent modification; treat versions as opaque validators, not sortable counters.

A User `active=false` replacement and a Group member removal are separate resource changes. SCIM does not make that pair one cross-resource transaction. Nor does a protocol success automatically revoke a product’s existing sessions or remote jobs. Document which application effects are synchronous, which are pending, and how their completion is observed.

The minimal request below illustrates a resource update only. Authentication credentials and real resource IDs come from the configured connector; no runnable server is supplied by this snippet.

```http
PATCH /scim/v2/Users/u-acme-42 HTTP/1.1
Host: app.example
Content-Type: application/scim+json
If-Match: W/"user-version-17"

{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[{"op":"replace","path":"active","value":false}]}
```

## Define the local transition that disables authority

In the accompanying model, one local transaction changes active state, advances an authorization epoch, replaces local grants, revokes credentials and cancels pending user jobs. The event receipt commits with that transition. An interruption before commit leaves the previous complete state, so a retry can safely try again. A half-disabled account is not an acceptable success state.

At request time, compare credential epoch with the current user epoch and check the current grant needed for the action. At job execution, check again rather than trusting authority captured at enqueue time. Cancellation is useful cleanup, but the worker’s effect-time check is an independent defense if cancellation delivery is missed.

Re-enabling a user is a new explicit transition. It must not revive an old bearer credential, refresh grant or pending job. Restore only approved present-day grants, issue new credentials through the normal flow, and retain the prior revocation history. An effect completed before disable is historical fact; disable does not automatically reverse it.

The epoch only helps if every enforcement point reads it. Session middleware, API token validation, GraphQL resolvers, exports and background workers must all compare the credential’s epoch with the user’s current one, or call a shared function that does. The session guide at /domains/identity-crypto/authentication/session-lifecycle/ shows the same version check for browser sessions.

## How do other services learn that access has ended?

A local disable does not reach sessions held by other applications. The OpenID Shared Signals Framework and the Continuous Access Evaluation Profile (CAEP), both final specifications since August 2025, let an identity provider push signed Security Event Tokens (SETs) such as `session-revoked` and `credential-change` to every service that subscribed to a stream. Your application can be a receiver, ending its own sessions when the identity provider says so, and a transmitter, telling downstream services when it disables a user.

For OAuth tokens your application holds at other services, such as integration refresh tokens stored for the departing user, call each authorization server’s revocation endpoint (RFC 7009). If your application issues tokens, give resource servers a way to learn about revocation, such as short access-token lifetimes plus token introspection (RFC 7662), because a self-contained JWT stays valid until it expires; the JWT guide at /domains/identity-crypto/authentication/jwt-attacks/ covers access-token lifetimes.

The receiver below verifies each SET against the transmitter’s keys, refuses anything that is not explicitly typed as a security event, acts once per `jti` because deliveries are retried, and revokes sessions for the subject named in `sub_id`. It was tested with jose 6.2.12 on Node.js 26: a session-revoked event revokes once even when delivered twice, and a forged signature, a plain JWT type, another issuer and another audience are rejected before any revocation.

```javascript
import {createRemoteJWKSet, jwtVerify} from 'jose';

const SESSION_REVOKED = 'https://schemas.openid.net/secevent/caep/event-type/session-revoked';
const CREDENTIAL_CHANGE = 'https://schemas.openid.net/secevent/caep/event-type/credential-change';
// The transmitter's issuer and JWKS come from its SSF configuration, set up when the stream was created.
const transmitterKeys = createRemoteJWKSet(new URL('https://idp.example/ssf/jwks'));

// POST /ssf/events (RFC 8935 push delivery, Content-Type: application/secevent+jwt).
// Answer 202 when this resolves; 400 when it throws.
export async function receiveSet(token, {seenJti, revokeSessions}) {
  const {payload} = await jwtVerify(token, transmitterKeys, {
    issuer: 'https://idp.example/',
    audience: 'https://app.example/ssf',
    typ: 'secevent+jwt', // SSF requires explicit typing, so an ID or access token is refused
    algorithms: ['RS256', 'ES256'],
  });
  if (!(await seenJti.add(payload.jti))) return 'duplicate'; // deliveries are retried: act once per jti
  const [[type, event]] = Object.entries(payload.events); // SSF: one event per SET
  if (type === SESSION_REVOKED || type === CREDENTIAL_CHANGE) {
    // sub_id names the subject, for example {format: 'iss_sub', iss, sub}; map it within the tenant.
    await revokeSessions(payload.sub_id, event.event_timestamp);
  }
  return 'accepted';
}

```

## Handle retries and stale state without inventing protocol guarantees

For repeated delivery, distinguish the same change from a conflicting reuse of an operation identifier. The model uses adapter-assigned event IDs plus monotonically ordered source versions; those are declared model assumptions, not SCIM-defined event fields. A real connector needs a supported source snapshot/version or reconciliation strategy before it can claim stale updates cannot reactivate users. RFC 9967 (May 2026) adds an optional SCIM profile for Security Event Tokens that a provider can use to signal changes asynchronously; check that your directory supports it before relying on it.

After an ambiguous network result, read current resource state and reconcile deliberately. Do not blindly resend a stale full-resource replacement that restores removed grants. Measure the time from authoritative disable to denial at each enforcement point, alert on stalled propagation, and retain enough identifiers to prove which tenant and identity were affected.

## Prove disable from the user’s remaining entry points

Keep the old session, token and queued job alive in the test environment before sending a lifecycle update. Try each after the change and after an application restart. Also test an unrelated tenant with a colliding email/identifier; broad accidental revocation is a defect too.

| Action | Required observation | Important limit |
| --- | --- | --- |
| Disable Acme user | Old session/token denied; pending user job cannot execute | New login rejection alone is insufficient |
| Remove export membership | Read still works with a new authorized credential; export fails | Other policy grants may independently permit export |
| Retry the same accepted transition | No second epoch change or repeated side effect | A changed body under the same event ID conflicts |
| Re-enable later | New credentials and explicit grants only | Old credentials and jobs stay invalid |
| External revocation pending | Pending/failed target and elapsed time are visible | Local commit is not proof of remote completion |

## Run the durable lifecycle model

The identity-lifecycle downloadable project uses Python 3.11 or later and SQLite. Run `python3 -m unittest -v test_lifecycle.py`. Repair `exercise.py`, then compare `reference.py` after recording the failing boundaries. All 18 reference tests execute real local transactions, credentials and grants, and job-effect records.

The model uses normalized in-process provisioning snapshots and opaque credential IDs. It does not implement SCIM HTTP parsing, actual signed token verification or an external revocation service. Those limitations identify the next integration tests; they do not turn the local transaction tests into protocol compliance evidence.

## Guided practice

Work through an offboarding race and decide whether a successful resource update has actually ended the user’s authority.

### Supplied material

```text
Application contract: disable invalidates all user credentials, removes local grants and cancels pending user jobs. Every request and worker checks current active/epoch/grant state.
Tenant Acme: connector C-A; SCIM id u-acme-42; externalId 42; email person@example.test.
Tenant Beta: connector C-B; SCIM id u-beta-42; externalId 42; same email; unaffected account.
Acme initial state: active=true, epoch=7, grants=[read,export].
Session S7 and personal token T7 both carry epoch=7; export job J7 is pending at epoch=7.
09:00:00 directory disables Acme user.
09:00:02 C-A sends the active=false PATCH shown above; application commits its full local disable transition, epoch=8, and returns success.
09:00:03 client retries after losing the response; it supplies an identical normalized adapter event E18.
09:00:04 a delayed adapter snapshot E17 arrives claiming active=true.
09:00:05 S7 calls /invoices; T7 calls /exports; worker attempts J7.
09:05:00 an approved new snapshot E19 enables user with grants=[read], epoch=9.
Assumption for this PAPER MODEL only: E17/E18/E19 have trusted ordered source versions 17/18/19; event IDs and ordering are not SCIM wire fields.
Remote analytics service revocation remains unacknowledged at 09:00:05.
```

### 1. Inventory the local disable result

Write every state change required at 09:00:02, and name the tenant-bound identity key.

Worked answer: Resolve C-A to Acme and immutable user `u-acme-42` using its enrolled mapping. Commit `active=false`, epoch 8, empty local grants, revoked S7/T7 and cancelled J7 together with the adapter receipt. Do not look up a user globally by email or `externalId`; Beta’s `u-beta-42` remains untouched.

### 2. Handle duplicate and stale inputs

Evaluate E18 again and then E17 using only the explicit model assumption.

Worked answer: Identical E18 is a duplicate and must not advance the epoch again. A changed body under E18 would conflict. E17 is stale under the supplied trusted ordering and cannot restore active status or grants. Do not infer equivalent ordering by numerically parsing an arbitrary SCIM `ETag`.

### 3. Evaluate old credentials and queued work

Decide the outcomes of all three 09:00:05 attempts, including the case where a cancellation message was lost.

Worked answer: All deny. S7 and T7 are revoked and their epoch 7 differs from current epoch 8; the user is inactive. J7 cannot execute even if its pending flag survives because the worker checks current active state, epoch and export grant at effect time.

### 4. Distinguish local completion from remote completion

State what evidence the 09:00:02 success proves and whether offboarding is fully complete by 09:00:05.

Worked answer: It proves the agreed local resource/application transition committed. It does not prove analytics revocation. Offboarding remains incomplete for that target; report the unacknowledged target and elapsed time from 09:00:00, retry/reconcile under the service contract and escalate if its revocation objective is exceeded.

### 5. Re-enable without reviving old authority

At 09:05:00 determine whether T7 and J7 return to service, and what a new session may do.

Worked answer: T7 and J7 stay invalid. A newly issued session at epoch 9 may read because E19 explicitly grants read, but cannot export without another current grant. Historical completed effects remain auditable; re-enable does not repeat them.

Deliverable: A tenant-scoped before/after authority table, a duplicate/stale-change decision log, and an offboarding completion record that distinguishes local and remote targets.

Scope: The ordered event timeline is a declared application model, not a standardized SCIM event stream. Run the project for local transaction evidence, then test a real authenticated SCIM connector and each remote enforcement point.

## Executable practice

- [Prove deprovisioning reaches sessions and queued work: project ZIP](https://softwaresecurity.com/downloads/projects/identity-lifecycle-510849ac517b9ec2.zip) — Python 3.11+ · standard library

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [RFC 7643 — SCIM Core Schema, identifiers, active and groups](https://www.rfc-editor.org/rfc/rfc7643.html). September 2015; updated by RFC 9865 and RFC 9967.
- [RFC 7644 — SCIM PATCH, versioning and multi-tenancy](https://www.rfc-editor.org/rfc/rfc7644.html). September 2015; updated by RFC 9865 and RFC 9967.
- [RFC 7642 — provisioning and deprovisioning concepts](https://www.rfc-editor.org/rfc/rfc7642.html). September 2015.
- [SQLite — transaction behavior used by the runnable model](https://sqlite.org/lang_transaction.html)
- [OpenID Shared Signals Framework 1.0](https://openid.net/specs/openid-sharedsignals-framework-1_0-final.html). Final, 29 August 2025.
- [OpenID Continuous Access Evaluation Profile (CAEP) 1.0](https://openid.net/specs/openid-caep-1_0-final.html). Final, 29 August 2025.
- [RFC 7009: OAuth 2.0 Token Revocation](https://www.rfc-editor.org/rfc/rfc7009.html). August 2013.
- [RFC 8935: Push-based Security Event Token delivery](https://www.rfc-editor.org/rfc/rfc8935.html). November 2020.
- [RFC 9967: SCIM profile for Security Event Tokens](https://www.rfc-editor.org/rfc/rfc9967.html). May 2026.