LabHub

Blog

SCIM 2.0 Deep Dive — The Standard for Automated User Provisioning

한국어English日本語

Introduction — SSO Is Only Half the Answer

When people talk about enterprise IAM, most of the discussion gravitates toward SSO (Single Sign-On): SAML versus OIDC, which IdP to use, and so on. But anyone who has actually rolled out SSO in production quickly runs into the next question.

"Login works — but who creates the accounts? And who deletes them when someone leaves?"

This is the problem of provisioning, and as of 2026 the de facto standard answer is SCIM 2.0 (System for Cross-domain Identity Management). In an era where Zero Trust preaches "identity-first," account lifecycle automation is no longer optional — it is a security prerequisite. With non-human identities such as AI agents entering the picture, manual account management has become unsustainable both operationally and from a security standpoint.

This post walks through the SCIM 2.0 spec structure, concrete HTTP examples, the state of support in major IdPs (Okta, Microsoft Entra ID, Keycloak), and the traps you are likely to fall into when implementing a SCIM server yourself.

Why Provisioning Matters as Much as SSO — The JML Lifecycle

The account lifecycle is commonly described with the JML (Joiner / Mover / Leaver) model.

StageEventRequired actionsRisk if it fails
JoinerNew hire, onboardingCreate accounts, assign groups/rolesOnboarding delays, lost productivity
MoverDepartment transfer, role changeRe-adjust permissions, change groupsPrivilege creep
LeaverResignation, contract endDeactivate accounts, revoke sessions/tokensEx-employee access, data exfiltration

SSO only covers the "moment of authentication." JML covers the "lifetime of an account." With SSO but no provisioning, the following happens.

Leaver handling in particular is directly tied to security incidents. Blocking SSO only prevents "new logins"; already-issued sessions, refresh tokens, and app-local accounts must be revoked separately. The standardized channel for automating that revocation is SCIM deprovisioning.

SCIM 2.0 Spec Structure — A Standard Made of Three RFCs

SCIM 2.0 was standardized by the IETF in 2015 across three RFCs.

RFCTitleRole
RFC 7642Definitions, Overview, Concepts, and RequirementsTerminology, use cases, requirements
RFC 7643Core SchemaUser/Group resource schemas, extension model
RFC 7644ProtocolREST API, filters, PATCH, bulk, pagination

Here is the structure in a single picture.

+----------------------------+         +----------------------------+
|  IdP / HR system           |  SCIM   |  SP (SaaS app)             |
|  (SCIM client)             | ------> |  (SCIM server)             |
|                            |  HTTPS  |                            |
|  - Okta                    |         |  - POST   /Users           |
|  - Entra ID                |         |  - GET    /Users?filter=   |
|  - Keycloak + custom       |         |  - PATCH  /Users/id        |
|  - Workday and other HR    |         |  - DELETE /Users/id        |
+----------------------------+         +----------------------------+
     RFC 7642: concepts        RFC 7643: schema     RFC 7644: protocol

The key point is the division of roles: typically the IdP acts as the SCIM client and the SaaS app (SP) acts as the SCIM server. When a vendor says "we support SCIM," what they mean is "we implemented a SCIM server."

User / Group Schemas and the Extension Model

The Core User schema

These are the representative attributes of the User resource defined by RFC 7643.

{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
  "id": "2819c223-7f76-453a-919d-413861904646",
  "externalId": "emp-10042",
  "userName": "yjkim@example.com",
  "name": {
    "familyName": "Kim",
    "givenName": "Youngju"
  },
  "displayName": "Youngju Kim",
  "emails": [
    {
      "value": "yjkim@example.com",
      "type": "work",
      "primary": true
    }
  ],
  "active": true,
  "groups": [
    {
      "value": "e9e30dba-f08f-4109-8486-d5c6a331660a",
      "display": "platform-team"
    }
  ],
  "meta": {
    "resourceType": "User",
    "created": "2026-06-12T09:00:00Z",
    "lastModified": "2026-06-12T09:00:00Z",
    "version": "W/\"3694e05e9dff590\"",
    "location": "https://api.example.com/scim/v2/Users/2819c223-7f76-453a-919d-413861904646"
  }
}

The attributes worth paying close attention to:

The Group schema

{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
  "id": "e9e30dba-f08f-4109-8486-d5c6a331660a",
  "displayName": "platform-team",
  "members": [
    {
      "value": "2819c223-7f76-453a-919d-413861904646",
      "display": "yjkim@example.com",
      "type": "User"
    }
  ]
}

Group looks simple but is the most painful resource in practice. Some IdPs replace the entire members list of a several-thousand-member group with a single PUT, while others add/remove members one at a time via PATCH — your server implementation has to survive both.

Enterprise User extension and custom extensions

HR attributes (department, employee number, manager, and so on) are expressed via the Enterprise User extension schema.

{
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:User",
    "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
  ],
  "userName": "yjkim@example.com",
  "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
    "employeeNumber": "10042",
    "department": "Platform Engineering",
    "costCenter": "CC-3120",
    "manager": {
      "value": "26118915-6090-4610-87e4-49d8ca9f808d",
      "displayName": "Jane Doe"
    }
  }
}

If you need your own attributes, you can define a custom extension schema under your own URN namespace, advertised by the server at the /Schemas endpoint. However, custom extensions are only usable in practice if the IdP attribute-mapping UI supports them, so staying within the Enterprise extension whenever possible is the safer compatibility bet.

The Protocol — REST Endpoints with HTTP Examples

The full list of endpoints defined by RFC 7644:

MethodPathPurpose
POST/UsersCreate a user
GET/UsersList + filter search
GET/Users/idRetrieve one user
PUT/Users/idFull replacement
PATCH/Users/idPartial modification
DELETE/Users/idDelete
GET/ServiceProviderConfigAdvertise server capabilities
GET/SchemasAdvertise schemas
GET/ResourceTypesAdvertise resource types
POST/BulkBulk operations (optional)

Creating a user — POST

POST /scim/v2/Users HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Content-Type: application/scim+json

{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
  "externalId": "emp-10042",
  "userName": "yjkim@example.com",
  "name": { "givenName": "Youngju", "familyName": "Kim" },
  "emails": [{ "value": "yjkim@example.com", "type": "work", "primary": true }],
  "active": true
}

On success the server returns 201 Created together with the full resource including the populated id and meta. If the userName already exists, the server must respond with 409 Conflict carrying a SCIM error body.

HTTP/1.1 409 Conflict
Content-Type: application/scim+json

{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
  "scimType": "uniqueness",
  "detail": "userName yjkim@example.com already exists",
  "status": "409"
}

Filtered search — GET with filter queries

Before pushing, the IdP checks "does this user already exist?" using a filter. This is the most frequently invoked pattern.

GET /scim/v2/Users?filter=userName%20eq%20%22yjkim%40example.com%22 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

Decoded, the filter reads:

filter=userName eq "yjkim@example.com"

The RFC 7644 filter grammar supports the operators eq, ne, co (contains), sw (starts with), gt, ge, lt, le, pr (present), plus and/or/not combinators, parentheses, and complex attribute paths.

filter=emails[type eq "work" and value co "@example.com"]
filter=meta.lastModified gt "2026-06-01T00:00:00Z"
filter=userName sw "yj" and active eq true

In reality, though, the filters real IdPs send boil down to two: "userName eq ..." and "externalId eq ...". When implementing a server, the pragmatic strategy is to support those two patterns flawlessly first and expand the rest incrementally rather than chasing full grammar coverage.

Partial modification — PATCH

PATCH is the most complex part of SCIM. It carries a list of operations, each with one of three ops: add / remove / replace.

PATCH /scim/v2/Users/2819c223-7f76-453a-919d-413861904646 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Content-Type: application/scim+json

{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
  "Operations": [
    { "op": "replace", "path": "active", "value": false },
    { "op": "replace", "path": "name.familyName", "value": "Lee" },
    {
      "op": "add",
      "path": "emails",
      "value": [{ "value": "yj.lee@example.com", "type": "work" }]
    }
  ]
}

Group membership manipulation uses paths containing a value filter.

{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
  "Operations": [
    {
      "op": "remove",
      "path": "members[value eq \"2819c223-7f76-453a-919d-413861904646\"]"
    },
    {
      "op": "add",
      "path": "members",
      "value": [{ "value": "08b0fe34-0ec4-4857-b8e2-58dbccca2f48" }]
    }
  ]
}

Full replacement — PUT

PUT replaces the entire resource. It is simple but dangerous: it can wipe out attributes the client does not know about (attributes the server manages internally). The server must therefore preserve readOnly/immutable attributes regardless of what arrives in the PUT body.

Push Model vs Pull Model

AspectPush (IdP to SP)Pull (SP from IdP/HR)
DirectionIdP calls the SP SCIM API on changeSP polls the source periodically
LatencyNear real time (event driven)Bound by polling interval (tens of minutes to hours)
Typical examplesOkta and Entra ID SaaS provisioningParts of Entra ID on-prem HR integration
SP implementation burdenMust implement a SCIM serverMust implement a SCIM client
Failure handlingRelies on IdP retry queuesSelf-heals on next poll

The industry mainstream is the push model. Both Okta and Entra ID detect change events in their directories and push them to the SP SCIM endpoint. An interesting nuance is how Entra ID behaves: it is not pure event push but accumulates changes within a synchronization cycle of roughly 40 minutes. Most "Entra provisioning is not applying immediately" support tickets come down to this cycle.

In the push model, the one thing the SP absolutely must consider is idempotency. The IdP retries the same request on network errors, so two POSTs with the same externalId must never produce duplicate accounts.

SCIM Support in Major IdPs (2026)

Okta

Microsoft Entra ID (formerly Azure AD)

Keycloak

The skeleton of a push-style integration using the event listener SPI looks like this.

public class ScimPushEventListener implements EventListenerProvider {

    private final ScimClient scimClient;

    @Override
    public void onEvent(AdminEvent event, boolean includeRepresentation) {
        if (event.getResourceType() != ResourceType.USER) {
            return;
        }
        switch (event.getOperationType()) {
            case CREATE -> scimClient.createUser(toScimUser(event));
            case UPDATE -> scimClient.patchUser(toScimPatch(event));
            case DELETE -> scimClient.deactivateUser(extractUserId(event));
        }
    }

    @Override
    public void close() {
        // no-op
    }
}

In production you should pair this with an outbox table and a retry queue to guard against event loss.

Traps When Implementing a SCIM Server

Trap 1 — The complexity of PATCH semantics

PATCH accounts for eighty percent of SCIM implementation difficulty.

Rather than writing your own parser, use a proven library (for Java, the UnboundID SCIM 2 SDK and similar).

Trap 2 — ETags and concurrency

If the HR system and an IdP administrator modify the same user concurrently, the last write wins (lost update). SCIM defines ETag-based optimistic locking for this.

PUT /scim/v2/Users/2819c223-7f76-453a-919d-413861904646 HTTP/1.1
If-Match: W/"3694e05e9dff590"
Content-Type: application/scim+json

If the version differs, the server returns 412 Precondition Failed. In reality, though, most IdP clients never send If-Match, so the right call is to implement ETags as "supported but not enforced" and advertise that honestly in ServiceProviderConfig.

{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
  "patch": { "supported": true },
  "bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 },
  "filter": { "supported": true, "maxResults": 200 },
  "etag": { "supported": true },
  "changePassword": { "supported": false },
  "sort": { "supported": false },
  "authenticationSchemes": [
    {
      "type": "oauthbearertoken",
      "name": "OAuth Bearer Token",
      "description": "Authorization header with Bearer token"
    }
  ]
}

Trap 3 — Pagination

SCIM default pagination is startIndex based, starting at 1.

GET /scim/v2/Users?startIndex=101&count=100 HTTP/1.1
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
  "totalResults": 5042,
  "startIndex": 101,
  "itemsPerPage": 100,
  "Resources": []
}

The traps:

Trap 4 — Error response format

SCIM clients branch on the scimType in the error body. If you do not return 401/403/404/409/412 in the exact SCIM error format, some IdPs decide the endpoint is "unhealthy" and quarantine provisioning. The Entra ID quarantine state is the canonical example.

Deprovisioning and Security

Leaver handling is the most security-critical flow. The recommended design:

[HR: termination processed]
      |
      v
[IdP: deactivate account] --> SCIM PATCH active=false --> [SP: soft-delete]
      |                                                       |
      v                                                       v
[Revoke all IdP sessions]                        [Revoke SP sessions/refresh tokens]
                                                 [Invalidate API keys, PATs]
                                                 [Hard-delete after retention period]

Core principles:

  1. Prefer active=false over DELETE — audit trails and (legally required) data retention make immediate hard deletes a bad idea. Most IdPs default to "deactivate" as well.
  2. SCIM does not kill sessions — active=false only blocks "new authentication." When the SP receives this signal, it must proactively revoke the user's active sessions and refresh tokens. The complementary standard filling this gap is the OpenID Shared Signals Framework / CAEP, which major vendors are steadily adopting as of 2026.
  3. Do not forget non-human identities — service accounts, bot tokens, and AI-agent delegations created by the departed employee often fall outside SCIM scope. Design a separate ownership-transfer process.

HR-driven Provisioning Architecture

The end state for mature organizations is to treat the HR system as the single source of truth.

+-----------+     +---------------------+     +------------------------+
| HR system | --> | IdP                 | --> | SaaS app 1 (SCIM server)|
| (Workday  |     | (Okta / Entra /     | --> | SaaS app 2 (SCIM server)|
|  etc.)    |     |  Keycloak)          | --> | Internal   (SCIM server)|
+-----------+     +---------------------+     +------------------------+
 join/move/leave    group/role mapping          account/role updates
 events published   rules (dept -> group        session/token revocation
                    -> app role)

Design points:

Testing Strategy

A test matrix to run through before shipping a SCIM server:

CategoryTest items
CreateHappy-path POST, duplicate userName 409, missing required attribute 400
ReaduserName/externalId filters, case insensitivity, URL encoding
ModifyPATCH add/remove/replace, PATCH without path, value filter paths
ReplacereadOnly attributes preserved on PUT, handling of omitted attributes
Deactivateactive=false triggers session/token revocation
PaginationstartIndex starting at 1, boundary values, empty results
ConcurrencyETag mismatch 412, concurrent PATCH
AuthExpired token 401, insufficient privileges 403
IdempotencyRetried identical POST, re-applied identical PATCH

On the tooling side:

#!/usr/bin/env bash
set -euo pipefail

BASE="https://api.example.com/scim/v2"
TOKEN="$SCIM_TEST_TOKEN"

# 1. Create
USER_ID=$(curl -sf -X POST "$BASE/Users" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/scim+json" \
  -d @fixtures/user-create.json | jq -r '.id')

# 2. Filtered lookup
curl -sf "$BASE/Users?filter=userName%20eq%20%22scim-test%40example.com%22" \
  -H "Authorization: Bearer $TOKEN" | jq -e '.totalResults == 1'

# 3. Deactivate
curl -sf -X PATCH "$BASE/Users/$USER_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/scim+json" \
  -d '{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
       "Operations":[{"op":"replace","path":"active","value":false}]}' \
  | jq -e '.active == false'

# 4. Clean up
curl -sf -X DELETE "$BASE/Users/$USER_ID" -H "Authorization: Bearer $TOKEN"
echo "SCIM smoke test passed"

The most reliable approach is to keep real IdP tenants (an Okta developer org, an Entra test tenant) wired to your staging environment and run regression tests with traffic from real IdPs on every release. IdP-specific implementation quirks will never be fully captured by reading documentation alone.

Operational Best Practices

Closing Thoughts

SCIM 2.0 is not a glamorous technology. But if SSO is the "front door," SCIM is the "roster management" — and most security incidents originate in the roster, not the door. In the enterprise B2B market of 2026, SCIM support has long since joined SSO on the checklist for enterprise deals.

To summarize:

  1. SSO and provisioning are separate problems; identity-first security only holds once the entire JML lifecycle is automated.
  2. Understand SCIM 2.0 along its three axes: RFC 7642 (concepts), 7643 (schema), 7644 (protocol).
  3. The hard parts of a server implementation are PATCH semantics, pagination, error formats, and idempotency. Tame them with proven SDKs and tests against real IdPs.
  4. Deprovisioning does not end at active=false. Design through to session and token revocation, and look toward combining with event standards such as Shared Signals/CAEP in the long run.

In the next post, we will cover how to design per-customer SSO in a multi-tenant SaaS, and how to make SCIM onboarding self-service on top of it.

References

Comments

No comments yet.

Sign in to leave a comment