How to Implement SCIM 2.0 Provisioning: User Lifecycle, Group Sync and Deprovisioning for B2B SaaS (2026)

How to Implement SCIM 2.0 Provisioning: User Lifecycle, Group Sync and Deprovisioning for B2B SaaS (2026)

How to Implement SCIM 2.0 Provisioning: User Lifecycle, Group Sync and Deprovisioning for B2B SaaS

An employee leaves a customer on Friday afternoon. Their identity provider account is disabled within minutes, but their session in your product, their API keys and their seat stay alive until someone remembers to file a ticket. That gap is what scim provisioning exists to close, and it is why enterprise security questionnaires now ask about it right after single sign-on.

It matters more in 2026 because the standard is moving again. The IETF published cursor-based pagination as RFC 9865 in October 2025 and a Security Event Token profile for SCIM as RFC 9967 in May 2026, after almost a decade in which the 2015 core RFCs stood alone. Meanwhile the two clients you will meet most, Okta and Microsoft Entra ID, still interpret those RFCs differently.

You will leave with a ten-step implementation plan, a tested multi-tenant FastAPI server of under 250 lines, a map of what Okta and Entra actually send, and a deprovisioning design that removes access rather than just flipping a flag.

What this covers: SCIM 2.0 resources and endpoints, identifiers and uniqueness, filtering and pagination, the PATCH engine, ETags, group sync, joiner-mover-leaver flows, soft versus hard delete, IdP quirks, testing and failure modes.

Context and Background

SCIM, the System for Cross-domain Identity Management, is a REST and JSON protocol for creating, reading, updating and deleting identities in another system. It began as “Simple Cloud Identity Management” in the early 2010s and was standardised at the IETF as three documents published in September 2015. RFC 7642 is the informational overview of concepts and use cases. RFC 7643 defines the core schema: the User and Group resources, the Enterprise User extension and the attribute characteristics that govern them. RFC 7644 defines the protocol: endpoints, HTTP methods, filtering, PATCH, bulk operations, ETags and errors.

The roles are fixed. The identity provider (IdP), such as Okta, Microsoft Entra ID, OneLogin or JumpCloud, acts as the SCIM client. Your B2B SaaS application is the SCIM service provider. The IdP decides who should have an account, and your application stores and enforces that decision. Nothing in SCIM authenticates the end user; that is still the job of SAML or OpenID Connect.

That separation explains why SSO alone is not enough. With SAML or OIDC just-in-time (JIT) provisioning, an account is created the first time a person signs in. JIT handles joiners well but handles leavers badly, because a person who never signs in again generates no event. Your product never learns they left. It keeps billing for the seat and keeps any long-lived credential they minted. Proper user provisioning sso pairs a login protocol with a lifecycle protocol, and SCIM is the lifecycle half. If your login side still has rough edges, our guide to OAuth 2.1, PKCE and OIDC JWT validation pitfalls covers that half.

The IETF SCIM working group was re-chartered and has been productive since 2024. Its datatracker page now lists RFC 9865 (cursor pagination, October 2025), RFC 9944 (device schema extensions, May 2026) and RFC 9967 (SCIM profile for Security Event Tokens, May 2026), with RFC 9865 and RFC 9967 formally updating RFCs 7643 and 7644. A long tail of individual drafts covers roles and entitlements, tenant-aware provisioning, AI agent resources and an interoperability profile. Drafts are not standards, so build on the RFCs and treat the drafts as signals of direction.

The incumbent clients matter as much as the RFCs. Okta and Entra ID cover most enterprise buyers, and both publish integration requirements that narrow, extend or bend the RFCs in specific ways. A server that is strictly compliant but ignores those quirks will fail certification for app galleries. A server that only mirrors one vendor’s behaviour will break on the other.

How SCIM Provisioning Works: The Reference Architecture

SCIM provisioning is a push protocol. The customer’s identity provider calls a tenant-scoped REST API on your service, authenticated with a per-tenant credential, to create, update, deactivate and delete Users and Groups. Your service persists those resources, maps them to internal accounts and roles, and enforces access changes immediately.

SCIM provisioning reference architecture with Okta and Entra ID calling a multi-tenant SCIM 2.0 API

Figure 1: Reference architecture for SCIM provisioning in a multi-tenant B2B SaaS. Each customer IdP authenticates with its own credential, the gateway resolves the tenant, and a SCIM facade translates resources into internal accounts, memberships and access revocations.

The diagram shows two customer IdPs calling the same base URL. An authentication layer maps the presented credential to exactly one tenant before any handler runs. The SCIM facade owns the protocol: filter parsing, PATCH semantics, ETags and error envelopes. Behind it, a mapping layer writes to your real account, membership and role tables, and an access-revocation worker reacts to deactivations by killing sessions and tokens. An audit log records every change with the IdP as actor.

The SCIM layer is a facade, not your user table

The most important design decision is to keep the SCIM representation separate from your domain model. RFC 7643 resources carry attributes your product does not care about, such as nickName, locale or the Enterprise User costCenter. Your domain model carries things SCIM never sees, such as billing state, product roles and per-feature flags. Store the SCIM resource as a document beside the domain record, keyed by tenant and SCIM id, and project only the fields you need.

This pays off at three moments. Okta builds a PUT body from your previous GET response, so anything you drop on read can vanish on the next write. Entra asks you to store values exactly in the format it sent. And when you change your internal model, the SCIM contract does not have to change with it.

Tenancy belongs in the credential, not the URL

You have two choices for multi-tenancy. You can put a tenant identifier in the path, such as /scim/v2/t/{tenantId}/Users, or you can derive the tenant from the credential and serve every customer at /scim/v2/Users. Deriving it from the credential is safer, because a token for tenant A can never address tenant B even if the URL is tampered with. If you put a tenant in the path for routing or logging, still validate that it matches the credential’s tenant and reject mismatches.

Every query that touches storage must be scoped by that tenant. A filter=userName eq "ada@example.com" that searches globally is a cross-tenant data leak, not a bug. Uniqueness is also per tenant: two customers can both have a user called admin@corp.example.

Discovery tells clients what you support

RFC 7644 defines three discovery endpoints. /ServiceProviderConfig advertises whether you support PATCH, bulk, filtering (and maxResults), password change, sorting and ETags, plus your authentication schemes. /ResourceTypes lists the resources you serve and their schema extensions. /Schemas returns attribute definitions, including each attribute’s mutability, returned, uniqueness and caseExact characteristics.

Discovery is not decorative. Entra’s documentation describes /Schemas as how gallery applications expose attributes for mapping, and RFC 9865 adds a pagination block to ServiceProviderConfig. Be truthful in it. Advertising bulk.supported: true when your bulk handler is a stub invites a client to send operations you cannot honour.

The resource model you are signing up for

RFC 7643 defines a User with one required attribute beyond id: userName, which must be unique within the service provider and is case-insensitive by default (caseExact is false). Common optional attributes are name (a complex attribute with givenName, familyName and friends), displayName, emails, phoneNumbers, active, externalId and groups. The Enterprise User extension, identified by urn:ietf:params:scim:schemas:extension:enterprise:2.0:User, adds employeeNumber, costCenter, organization, division, department and manager.

A Group has displayName and members. Each member carries a value (the member resource’s id) and a $ref URI. The User’s groups attribute is read-only in RFC 7643, which means membership is always changed through the Group resource, never by patching a user. Several first-time implementers get this backwards and then wonder why Okta’s Group Push changes never reach their tables.

Every resource also carries meta, with resourceType, created, lastModified, location and version. The version value is what you return as an HTTP ETag, which matters for concurrency later.

Step-by-Step: Implementing SCIM Provisioning with the SCIM 2.0 API

The steps below run in the order you should build SCIM provisioning into a product. Each one corresponds to a part of the runnable server later in the post, and each names the RFC section or vendor rule that drives it.

  1. Decide identifiers and uniqueness. Map SCIM id, externalId and userName to your keys before writing code.
  2. Scope authentication to a tenant. Issue per-tenant credentials and resolve the tenant before any handler runs.
  3. Publish discovery endpoints. Serve ServiceProviderConfig, ResourceTypes and Schemas truthfully.
  4. Implement create with conflict detection. Return 201 with Location and ETag, or 409 with scimType: uniqueness.
  5. Implement filtered search and pagination. Support eq and and at minimum, with stable ordering and integer paging fields.
  6. Build a forgiving PATCH engine. Accept path-less and filtered paths, case-insensitive ops and string booleans.
  7. Model the joiner-mover-leaver lifecycle. Treat active: false as the leaver signal and fan out revocation.
  8. Sync groups to roles. Apply member add and remove deltas idempotently and map groups to entitlements.
  9. Add ETags and idempotency. Use meta.version, If-Match and 412 to prevent lost updates.
  10. Test against real clients. Run the RFC tests, Okta’s and Entra’s validators and your own replay suite.

Step 1: Decide identifiers and uniqueness

SCIM gives each resource three identity-like fields, and confusing them causes most duplicate-user bugs. The id is assigned by your service, must be stable and must never be reused. The externalId is assigned by the client and is its own identifier for the person, such as an Okta user ID or an Entra object ID. The userName is a human-meaningful unique handle, usually an email address or UPN.

Use id as your primary key and generate it yourself, ideally as a UUID that encodes no tenant or sequence information. Store externalId per tenant and index it, because Entra matches users on both userName and externalId by default. Enforce userName uniqueness per tenant, case-insensitively, at the database level with a unique index on a lower-cased column. An application-level check alone will race when an IdP sends two creates in parallel.

Do not use the email address as an immutable key. Names change, domains get acquired and people marry. If your product links data to email, add a stable internal user ID and treat email as a mutable attribute. A rename then arrives as an ordinary update rather than as a delete and re-create that orphans the person’s data.

Step 2: Scope authentication to a tenant

RFC 7644 leaves authentication to the deployment and lists OAuth bearer tokens as the typical choice. In practice you need to support what the IdPs support. Microsoft’s endpoint guidance for Entra lists OAuth 2.0 client credentials as recommended, workload identity federation with RFC 7523 JWT bearer assertions as supported, long-lived bearer tokens as limited to existing and non-gallery apps, and the authorization code grant as retired. Okta’s guidance for integrations in its network supports header tokens, bearer tokens and OAuth 2.0 authorization code. Okta custom integrations built with its App Integration Wizard can also use Basic auth or client credentials.

The minimum viable design is a per-tenant secret token, generated with at least 128 bits of entropy, shown once, stored only as a hash and revocable from your admin console. Record the token’s tenant, creator and last-used timestamp. Allow two active tokens per tenant so customers can rotate without downtime. When you add client credentials, issue a distinct client per customer; Microsoft notes that a single app-wide credential is not supported for Entra.

Federation is the direction of travel. With workload identity federation, Entra presents a signed JWT, and you validate it against the tenant’s JSON Web Key Set (JWKS). No shared secret ever sits in the customer’s console. The same ideas underpin delegated machine identity more broadly, which we covered in AI agent identity and OAuth token exchange.

Network controls are a useful second layer. Microsoft publishes Entra’s provisioning source ranges under the AzureActiveDirectory service tag, and Microsoft requires TLS 1.2 with specific cipher suites and RSA keys of at least 2,048 bits. An allow-list helps, but never treat it as authentication.

Step 3: Publish discovery endpoints

Serve /ServiceProviderConfig, /ResourceTypes and /Schemas without tenant-specific content, and keep them consistent with your behaviour. A minimal ServiceProviderConfig for a typical B2B SaaS declares patch.supported: true, bulk.supported: false, filter.supported: true with a maxResults, etag.supported: true and an oauthbearertoken authentication scheme.

Bulk is worth declining at first. RFC 7644 Section 3.7 defines /Bulk with bulkId cross-references, failOnErrors and server limits on operations and payload size. It is genuinely hard to make bulk transactional and tenant-safe. Microsoft’s endpoint documentation states that Entra does not support /Bulk today, and the Okta behaviours documented below never require it. Add it when a customer’s import volumes justify it.

Step 4: Implement create with conflict detection

A POST /Users should validate the body, check uniqueness, assign id and meta, persist and return 201 Created. Include the full resource, a Location header and an ETag. Content type for every response is application/scim+json. Okta treats an empty create body as a failed operation, so always return the resource.

Duplicates return 409 Conflict with an error body whose scimType is uniqueness. Okta’s documentation says a 409 stops the job when the user already exists. Most IdPs avoid this by first searching for the user and only creating when the search is empty. That search is why Step 5 is not optional.

Strip anything you should not store. Okta sends a password attribute on create even when password sync is off. If your product uses SSO only, discard it rather than hashing and storing a value nobody will ever use. RFC 7643 marks password as returned: never, so it must not appear in responses.

Error bodies follow RFC 7644 Section 3.12. They carry the schema urn:ietf:params:scim:api:messages:2.0:Error, a status expressed as a string, a human-readable detail and, for 400 and 409 errors, a scimType. The defined types include invalidFilter, tooMany, uniqueness, mutability, invalidSyntax, invalidPath, noTarget, invalidValue, invalidVers and sensitive. Clients log these, so make detail useful to the customer’s identity admin.

SCIM 2.0 API create and deactivate sequence between Okta and a SCIM service provider

Figure 2: The typical joiner and leaver exchange. The IdP searches by userName, creates on an empty result, stores the returned id and later deactivates with a PATCH that sets active to false.

The sequence matters because the IdP stores your id and uses it for every later call. If your search misses an existing user because of case sensitivity or tenant scoping, the IdP will try to create a duplicate and receive a 409. If you return a different id on retry, the IdP’s link breaks and later updates land on nothing.

Step 5: Implement filtered search and pagination

RFC 7644 Section 3.4.2.2 defines a filter grammar with the operators eq, ne, co, sw, ew, pr, gt, ge, lt and le, logical and, or and not, grouping with parentheses and value filters on multi-valued attributes such as emails[type eq "work"]. You do not need all of it on day one. Microsoft states that Entra only sends eq and and, matching users on userName and externalId and groups on displayName. Okta’s documented existence check is GET /Users?filter=userName eq "{userName}"&startIndex=1&count=100.

Implement a real parser rather than regular expressions over the whole string, and reject anything you do not support with 400 and invalidFilter. Never translate filter text into SQL by concatenation. Map each attribute path to an allow-listed column or JSON path and bind the value as a parameter. Apply case-insensitive comparison for attributes whose caseExact is false, which includes userName.

A search with no matches returns 200 with a ListResponse whose totalResults is 0 and whose Resources array is empty. Microsoft specifies exactly that, and it is also how Entra’s connection test works. Okta documents that a 404 is also accepted for a missing user, but the empty list satisfies both clients, so prefer it.

Index-based pagination uses startIndex (1-based) and count. The response carries totalResults, startIndex and itemsPerPage. Okta asks for integers rather than strings and requires stable ordering across requests, because it advances startIndex by 100 while totalResults exceeds what it has read. If ordering is not stable, users are skipped or duplicated during imports. Sort by creation time and then by id as a tiebreaker.

Index paging gets expensive and inconsistent on large directories, because every page re-runs an OFFSET query against a moving dataset. RFC 9865 adds an optional cursor mode: the client sends cursor (empty on the first request), the server returns nextCursor and optionally previousCursor, and new error types invalidCursor, expiredCursor and invalidCount cover the failure cases. Support it if you can, advertise it in ServiceProviderConfig’s new pagination block, and keep index paging for clients that have not adopted it.

Step 6: Build a forgiving PATCH engine

PATCH is where most SCIM integrations break. RFC 7644 Section 3.5.2 defines a body with the schema urn:ietf:params:scim:api:messages:2.0:PatchOp and an Operations array. Each operation has an op of add, remove or replace, an optional path, and a value. Three path forms matter. A simple path such as active or name.givenName targets one attribute. A filtered path such as members[value eq "2819c223"] or emails[type eq "work"].value targets elements of a multi-valued attribute. A missing path means the value is an object whose keys are attribute paths.

The RFC semantics have sharp edges. A remove with no path fails with noTarget. A remove on a multi-valued attribute with no filter, such as path: "members", removes all values. An add to a multi-valued attribute appends, while a replace overwrites. The operations in one request are applied in order and, per the RFC, the request is atomic: if any operation fails, none of the changes should be kept.

Now add the real clients. Microsoft documents that Entra, without an opt-in flag, sends capitalised ops (Replace, Add, Remove), sends active as the string "False" rather than a boolean, and removes group members with path: "members" plus a value array listing the members to drop. Read literally, that last form would empty the group. The aadOptscim062020 feature flag, appended to the tenant URL, switches Entra to lower-case ops, boolean values, a single path-less replace with dotted and URN-qualified keys, and filtered member removal. Microsoft notes the flag does not apply to on-demand provisioning, so your endpoint will see both dialects.

Okta’s integrations in its network send deactivation as a path-less replace whose value is {"active": false}, use PATCH for group renames and membership changes, and send most other user updates as a full PUT built from your last GET response. Okta’s own groups API uses members[value eq "..."] for removal and add for new members.

The pragmatic rule is to be liberal in what you accept and strict in what you store. Lower-case the op before dispatch. Coerce "True" and "False" to booleans for boolean attributes. Treat a remove on members that carries a value array as a removal of exactly those members, which matches Microsoft’s documented intent. Apply operations to a deep copy of the resource and commit only if every operation succeeds. Respond with 200 and the resource for users and 204 for groups, which is what Microsoft asks for, since returning a 50,000-member list after every change helps nobody.

Mutability rules complete the picture. RFC 7643 attribute characteristics include readOnly, readWrite, immutable and writeOnly. Reject attempts to modify id or meta with mutability, and do the same for attributes you define as immutable. Silently ignoring a forbidden write hides misconfigured attribute mappings that the customer’s admin needs to fix.

Step 7: Model the joiner-mover-leaver lifecycle

Identity teams describe the employee lifecycle as joiner, mover and leaver. A joiner is a new assignment that becomes a create. A mover is an attribute or group change, such as a new department, manager or team, that becomes a PATCH or PUT. A leaver is unassignment or termination, and it is where SCIM earns its keep.

Joiner mover leaver state machine for deprovisioning automation with SCIM

Figure 3: The account lifecycle your SCIM service should implement. Active false is the leaver signal and immediately revokes access; hard delete is a separate, later, retention-driven step.

The critical fact is that IdPs mostly deactivate rather than delete. Okta’s documentation says it never sends DELETE for users; removing or deactivating a user in Okta sets active to false. It also notes that suspending a user in Okta generates no deprovisioning event at all. Entra sends active: false when a user is disabled or falls out of scope, and its documentation describes DELETE as the hard-delete path for when the user is removed from the application entirely. So active: false is the event your deprovisioning automation must trust.

Deactivation must do more than set a column. On active: false, revoke every live session, invalidate refresh tokens, disable or flag personal API keys and OAuth grants the user created, remove them from real-time channels, and stop counting them as a billable seat. Do this synchronously for session kill and token revocation, and asynchronously with retries for downstream systems. The PATCH should not return 200 until the user can no longer act.

A reactivation with active: true should restore the account without recreating it, so data ownership survives a leave of absence. Keep the soft-deleted user returnable by GET and by search; Microsoft explicitly requires that a disabled user still appears in queries, because Entra will otherwise try to create it again.

Hard delete is a data-retention decision, not a provisioning one. When a DELETE arrives, return 204 and either purge or tombstone according to the customer’s contract. Reassign or archive owned objects first. Do not let a DELETE cascade into deleting a customer’s shared documents because the author left. Keep a tombstone of id, externalId and deletion time so that a late PATCH for the same id gets a clean 404 rather than a confusing error.

Movers deserve care too. A department change that moves someone between groups arrives as two group PATCHes, a remove from one group and an add to another, possibly in separate requests minutes apart. If roles derive from groups, the person may briefly hold neither or both roles. Decide which is safer for your product and design for it. For most products a short window with fewer permissions beats a window with extra permissions.

Step 8: Sync groups to roles

Groups are the bridge between a customer’s directory and your authorisation model. Okta’s Group Push creates a group with displayName and an empty members list, then sends membership deltas via PATCH. It deletes the group only when an admin unlinks the pushed group and chooses deletion in the target application. Entra matches groups by displayName by default, and Microsoft requires group display names to be unique.

Store groups as SCIM resources and keep a separate, customer-controlled mapping from group to product role. Do not hard-code “a group called Admins grants admin”. Let the customer’s admin choose which pushed groups map to which roles, and default unmapped groups to no permissions. That keeps a typo in a group name from granting privileges.

Membership deltas must be idempotent. Adding a member who is already present is a no-op, and removing an absent member is a no-op. IdPs retry on timeouts, and a retry of a successful add must not fail or duplicate rows. Enforce a unique key on group ID and member ID in your membership table and use upsert semantics.

Large groups are where performance goes wrong. If your PATCH handler loads the full member list, mutates it in memory and writes it back, every one-member change costs O(n) and two concurrent changes overwrite each other. Store membership as rows, apply each operation as a targeted insert or delete, and never return the member list in a PATCH response. When a client does fetch a group, Okta documents that GET /Groups/{id} must include members without query parameters, so page or stream it efficiently.

Group-derived roles are coarse. If your product needs per-document or per-project permissions, use SCIM groups as the subject set and keep relationship data in a dedicated authorisation service; our comparison of ReBAC with Zanzibar, OpenFGA, SpiceDB and Cedar shows how to write SCIM membership changes into a relationship store as tuples.

Step 9: Add ETags and idempotency

RFC 7644 Section 3.14 defines optional resource versioning with ETags. You return the resource’s meta.version as an ETag header, clients send it back in If-Match, and a mismatch returns 412 Precondition Failed. Weak ETags such as W/"3694e05e" are allowed. Advertise support in ServiceProviderConfig under etag.

Most IdPs do not send If-Match today, so ETags protect you mainly from your own admin console, support scripts and other API clients racing the IdP. They still matter: Okta’s PUT-from-GET pattern means a user edited in your UI between Okta’s GET and PUT will have that edit silently reverted unless you detect the version change. Track versions server-side and record them in audit entries even when clients do not use them.

Creates are not naturally idempotent. If an IdP times out on a POST that actually succeeded, its retry will hit a 409. That is correct and recoverable, because the IdP then searches and links. Do not return 200 with the existing resource on duplicate create unless you are certain the request came from the same tenant and refers to the same externalId; otherwise you risk linking two people to one account.

Step 10: Test against real clients

Test in three layers. First, unit-test your filter parser and PATCH engine with the exact payloads from Microsoft’s compliance page and Okta’s reference, including both Entra dialects. Second, run the vendors’ own tooling: Microsoft documents a SCIM validator and a gallery validation process for Entra, and Okta provides a SCIM spec test suite and requires testing during submission to its integration network. Third, point a real developer tenant of each IdP at a staging endpoint and run joiner, mover, leaver and group push scenarios end to end.

Record every inbound request in staging with headers redacted. Those recordings become a replay suite that catches regressions when you refactor the PATCH engine. They also become your evidence when a customer says “Okta shows success but the user still has access”.

The Runnable Example: A Multi-Tenant SCIM 2.0 Server in FastAPI

The server below implements the ten steps in a single file. It is deliberately small and stores data in memory, so it is a reference for protocol behaviour rather than a production service. It was run against a scripted test of create, duplicate create, case-insensitive search, cross-tenant isolation, both Entra PATCH dialects, Enterprise User attributes, ETag mismatch, group member add and filtered remove, unsupported filters, delete and bad tokens before publication.

# scim_server.py - minimal multi-tenant SCIM 2.0 service provider (RFC 7643/7644)
# Run: pip install fastapi uvicorn && uvicorn scim_server:app --port 8080
import re, uuid, copy, datetime
from fastapi import FastAPI, Request, Header, HTTPException
from fastapi.responses import JSONResponse, Response

USER = "urn:ietf:params:scim:schemas:core:2.0:User"
GROUP = "urn:ietf:params:scim:schemas:core:2.0:Group"
ENT = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
LIST = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
PATCH = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
ERR = "urn:ietf:params:scim:api:messages:2.0:Error"
CT = "application/scim+json"

# token -> tenant. In production: hashed tokens or OAuth client credentials per tenant.
TOKENS = {"tenant-a-secret": "tenant-a", "tenant-b-secret": "tenant-b"}
DB = {}  # (tenant, kind) -> {id: resource}

app = FastAPI()

def now():
    return datetime.datetime.now(datetime.timezone.utc).isoformat()

def scim(body, status=200, etag=None, location=None):
    h = {}
    if etag: h["ETag"] = etag
    if location: h["Location"] = location
    return JSONResponse(body, status_code=status, media_type=CT, headers=h)

def error(status, detail, scim_type=None):
    body = {"schemas": [ERR], "status": str(status), "detail": detail}
    if scim_type: body["scimType"] = scim_type
    return scim(body, status)

class ScimError(Exception):
    def __init__(self, status, detail, scim_type=None):
        self.status, self.detail, self.scim_type = status, detail, scim_type

@app.exception_handler(ScimError)
async def on_scim_error(_req, exc):
    return error(exc.status, exc.detail, exc.scim_type)

def tenant_of(authorization):
    token = (authorization or "").removeprefix("Bearer ").strip()
    if token not in TOKENS:
        raise ScimError(401, "invalid token")
    return TOKENS[token]

def store(tenant, kind):
    return DB.setdefault((tenant, kind), {})

def stamp(res, kind, created=False):
    meta = res.setdefault("meta", {})
    meta["resourceType"] = kind
    if created: meta["created"] = now()
    meta["lastModified"] = now()
    meta["version"] = 'W/"%s"' % uuid.uuid4().hex[:12]
    meta["location"] = "/scim/v2/%ss/%s" % (kind, res["id"])
    return res

# ---------- filter: attrPath op "value" [and ...] (eq, ne, co, sw, ew, pr) ----------
TERM = re.compile(r'\s*([\w.:\-]+)\s+(eq|ne|co|sw|ew|pr)\s*("(?:[^"\\]|\\.)*"|true|false|null)?\s*', re.I)

def get_attr(res, path):
    if path.startswith(ENT + ":"):
        res, path = res.get(ENT, {}), path[len(ENT) + 1:]
    cur = res
    for part in path.split("."):
        if isinstance(cur, list):  # e.g. emails.value -> any element
            return [c.get(part) for c in cur if isinstance(c, dict)]
        if not isinstance(cur, dict): return None
        cur = next((v for k, v in cur.items() if k.lower() == part.lower()), None)
    return cur

def match_term(res, attr, op, raw):
    val = get_attr(res, attr)
    if op == "pr": return val not in (None, "", [])
    want = raw.strip('"') if raw and raw.startswith('"') else {"true": True, "false": False}.get(raw)
    vals = val if isinstance(val, list) else [val]
    for v in vals:
        a, b = (v.lower(), want.lower()) if isinstance(v, str) and isinstance(want, str) else (v, want)
        if op == "eq" and a == b: return True
        if op == "ne" and a != b: return True
        if isinstance(a, str) and isinstance(b, str):
            if (op == "co" and b in a) or (op == "sw" and a.startswith(b)) or (op == "ew" and a.endswith(b)):
                return True
    return False

def parse_filter(f):
    terms = re.split(r"\s+and\s+", f.strip(), flags=re.I)
    out = []
    for t in terms:
        m = TERM.fullmatch(t)
        if not m: raise ScimError(400, "unsupported filter: " + t, "invalidFilter")
        out.append((m.group(1), m.group(2).lower(), m.group(3)))
    return out

def list_response(items, start, count):
    start = max(1, start); count = max(0, min(count, 200))
    page = items[start - 1:start - 1 + count]
    return {"schemas": [LIST], "totalResults": len(items), "startIndex": start,
            "itemsPerPage": len(page), "Resources": page}

# ---------- discovery ----------
@app.get("/scim/v2/ServiceProviderConfig")
def spc():
    return scim({"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}, "changePassword": {"supported": False},
                 "sort": {"supported": False}, "etag": {"supported": True},
                 "authenticationSchemes": [{"type": "oauthbearertoken", "name": "Bearer token",
                                            "description": "Per-tenant bearer token"}]})

# ---------- generic CRUD for Users and Groups ----------
def kind_of(name):
    return {"Users": "User", "Groups": "Group"}.get(name) or (_ for _ in ()).throw(ScimError(404, "unknown resource type"))

def unique_key(kind, res):
    return (res.get("userName") or "").lower() if kind == "User" else (res.get("displayName") or "").lower()

@app.post("/scim/v2/{rt}")
async def create(rt: str, request: Request, authorization: str = Header(None)):
    tenant, kind = tenant_of(authorization), kind_of(rt)
    body = await request.json()
    key = unique_key(kind, body)
    if not key: raise ScimError(400, "userName/displayName required", "invalidValue")
    if any(unique_key(kind, r) == key for r in store(tenant, kind).values()):
        raise ScimError(409, "resource already exists", "uniqueness")
    body.pop("password", None)  # never persist IdP placeholder passwords
    body["id"] = str(uuid.uuid4())
    body.setdefault("schemas", [USER if kind == "User" else GROUP])
    if kind == "User": body.setdefault("active", True)
    if kind == "Group": body.setdefault("members", [])
    store(tenant, kind)[body["id"]] = stamp(body, kind, created=True)
    return scim(body, 201, body["meta"]["version"], body["meta"]["location"])

@app.get("/scim/v2/{rt}")
def search(rt: str, filter: str = None, startIndex: int = 1, count: int = 100,
           authorization: str = Header(None)):
    tenant, kind = tenant_of(authorization), kind_of(rt)
    items = sorted(store(tenant, kind).values(), key=lambda r: r["meta"]["created"])  # stable order
    if filter:
        terms = parse_filter(filter)
        items = [r for r in items if all(match_term(r, *t) for t in terms)]
    return scim(list_response(items, startIndex, count))

def load(tenant, kind, rid):
    res = store(tenant, kind).get(rid)
    if not res: raise ScimError(404, "resource %s not found" % rid)
    return res

def check_etag(res, if_match):
    if if_match and if_match.strip() != "*" and if_match.strip() != res["meta"]["version"]:
        raise ScimError(412, "version mismatch")

@app.get("/scim/v2/{rt}/{rid}")
def read(rt: str, rid: str, authorization: str = Header(None)):
    res = load(tenant_of(authorization), kind_of(rt), rid)
    return scim(res, 200, res["meta"]["version"])

@app.put("/scim/v2/{rt}/{rid}")
async def replace(rt: str, rid: str, request: Request, authorization: str = Header(None),
                  if_match: str = Header(None)):
    tenant, kind = tenant_of(authorization), kind_of(rt)
    old = load(tenant, kind, rid); check_etag(old, if_match)
    body = await request.json(); body.pop("password", None)
    body["id"], body["meta"] = rid, old["meta"]  # id and meta are readOnly
    store(tenant, kind)[rid] = stamp(body, kind)
    return scim(body, 200, body["meta"]["version"])

def to_bool(v):
    return v.lower() == "true" if isinstance(v, str) and v.lower() in ("true", "false") else v

MEMBER_FILTER = re.compile(r'members\[value eq "([^"]+)"\]', re.I)

def apply_op(res, op, path, value):
    if op not in ("add", "replace", "remove"):
        raise ScimError(400, "bad op " + op, "invalidSyntax")
    if path is None:  # path-less: value is an object of attribute -> value
        if op == "remove": raise ScimError(400, "remove needs a path", "noTarget")
        for k, v in (value or {}).items():
            apply_op(res, op, k, v)
        return
    m = MEMBER_FILTER.fullmatch(path)
    if m and op == "remove":
        res["members"] = [x for x in res.get("members", []) if x.get("value") != m.group(1)]
        return
    if path.lower() == "members":
        incoming = value if isinstance(value, list) else [value]
        current = res.setdefault("members", [])
        if op == "add":
            ids = {x["value"] for x in current}
            current.extend(x for x in incoming if x["value"] not in ids)
        elif op == "replace":
            res["members"] = incoming
        elif value:  # Entra-style remove with a value array: remove only listed members
            drop = {x["value"] for x in incoming}
            res["members"] = [x for x in current if x["value"] not in drop]
        else:  # RFC 7644: remove on a multi-valued attr with no filter clears it
            res["members"] = []
        return
    target, key = res, path
    if path.startswith(ENT + ":"):
        target, key = res.setdefault(ENT, {}), path[len(ENT) + 1:]
    parts = key.split(".")
    for p in parts[:-1]:
        target = target.setdefault(p, {})
    if op == "remove":
        target.pop(parts[-1], None)
    else:
        target[parts[-1]] = to_bool(value) if parts[-1].lower() == "active" else value

@app.patch("/scim/v2/{rt}/{rid}")
async def patch(rt: str, rid: str, request: Request, authorization: str = Header(None),
                if_match: str = Header(None)):
    tenant, kind = tenant_of(authorization), kind_of(rt)
    res = load(tenant, kind, rid); check_etag(res, if_match)
    body = await request.json()
    if PATCH not in body.get("schemas", []):
        raise ScimError(400, "missing PatchOp schema", "invalidSyntax")
    work = copy.deepcopy(res)
    for o in body.get("Operations", []):  # all-or-nothing: apply to a copy first
        apply_op(work, str(o.get("op", "")).lower(), o.get("path"), o.get("value"))
    store(tenant, kind)[rid] = stamp(work, kind)
    if kind == "Group":
        return Response(status_code=204)
    return scim(work, 200, work["meta"]["version"])

@app.delete("/scim/v2/{rt}/{rid}")
def delete(rt: str, rid: str, authorization: str = Header(None)):
    tenant, kind = tenant_of(authorization), kind_of(rt)
    load(tenant, kind, rid)
    del store(tenant, kind)[rid]
    if kind == "User":  # drop dangling memberships
        for g in store(tenant, "Group").values():
            g["members"] = [m for m in g.get("members", []) if m.get("value") != rid]
    return Response(status_code=204)

Walking through the code

Tenant resolution happens first in every handler. tenant_of maps the bearer token to a tenant and raises a SCIM-formatted 401 if it is unknown. Every read and write then goes through store(tenant, kind), so a token for one tenant cannot see another tenant’s users even with a valid id. In production, replace the dictionary with a hashed-token lookup and the in-memory store with a database where tenant ID is part of every primary key and unique index.

The filter parser accepts a conjunction of simple terms using eq, ne, co, sw, ew and pr. That covers everything Microsoft says Entra sends and Okta’s documented lookups. It compares strings case-insensitively, which is right for userName but too lenient for any attribute you declare caseExact. Anything it does not understand, such as gt or or, returns 400 with invalidFilter instead of silently returning everything.

The PATCH engine is where the compatibility work sits. It lower-cases the op, coerces string booleans on active, expands path-less operations into individual paths, handles URN-qualified Enterprise User paths, supports filtered member removal and treats a members removal with a value array as a targeted removal. It applies operations to a deep copy and commits only after all succeed, which gives you the RFC’s all-or-nothing behaviour.

Two shortcuts would not survive production. Uniqueness is checked by scanning, which races under concurrent creates; a unique index is required. And the generic PATCH path writes arbitrary attribute names, so a real service should validate paths against its schema and reject unknown or read-only attributes with invalidPath or mutability.

Exercising it with curl

Start the server with uvicorn scim_server:app --port 8080, then run a joiner and leaver cycle the way Okta would.

H='Authorization: Bearer tenant-a-secret'; CT='Content-Type: application/scim+json'
# 1. Existence check (Okta style) - expect totalResults 0
curl -s -H "$H" 'http://localhost:8080/scim/v2/Users?filter=userName%20eq%20%22ada@example.com%22&startIndex=1&count=100'
# 2. Create - expect 201, Location and ETag headers
curl -si -H "$H" -H "$CT" -X POST http://localhost:8080/scim/v2/Users -d '{
  "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],
  "userName":"ada@example.com","externalId":"00u1",
  "name":{"givenName":"Ada","familyName":"Lovelace"},
  "emails":[{"type":"work","value":"ada@example.com","primary":true}]}'
# 3. Deactivate (Entra legacy dialect) - expect "active": false
curl -s -H "$H" -H "$CT" -X PATCH http://localhost:8080/scim/v2/Users/<id> -d '{
  "schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
  "Operations":[{"op":"Replace","path":"active","value":"False"}]}'
# 4. Same filter with another tenant's token - expect totalResults 0
curl -s -H 'Authorization: Bearer tenant-b-secret' 'http://localhost:8080/scim/v2/Users?filter=userName%20eq%20%22ada@example.com%22'

To test against a real IdP, expose the server over HTTPS with a tunnel and point a developer tenant at it. Remember that Entra requires TLS 1.2 and a certificate chaining to a listed public root, so a self-signed certificate will fail the connection test.

Okta vs Entra SCIM: What Each Client Actually Sends

Okta and Entra are both SCIM 2.0 clients, but they differ on update verbs, PATCH dialect, deletion and timing. The table below summarises behaviours documented in each vendor’s own pages as of November 2026. Vendor behaviour changes, so recheck before certifying.

Behaviour Okta (integration network apps) Microsoft Entra ID
Existence check filter=userName eq "..."&startIndex=1&count=100 filter= with eq and and only, on userName, externalId; groups on displayName
User updates PUT built from your last GET; PATCH for activate, deactivate, password PATCH
PATCH op casing Lower case Capitalised (Replace) unless aadOptscim062020 flag is set
Deactivation Path-less replace with {"active": false} active set to string "False" by default; boolean with flag
User DELETE Never sent Sent for hard delete when removed from the app
Group member removal members[value eq "id"] path members path with value array by default; filtered path with flag
Import paging startIndex stepped by 100 Not documented as a full import client
Sync cadence Event-driven per change (no fixed interval stated) Cycle roughly every 40 minutes, plus on-demand provisioning
Bulk Not required Not supported
Auth options Header token, bearer, OAuth authorization code; custom apps also Basic and client credentials OAuth client credentials (recommended), workload identity federation; long-lived token only for existing or non-gallery apps

Two rows deserve emphasis. Entra’s roughly 40-minute cycle means a leaver can keep access for up to a cycle after being disabled in the directory, unless an admin triggers on-demand provisioning or the customer also relies on session-level controls. And Okta’s suspend action produces no SCIM event, so a suspended Okta user looks active to your service until something else changes.

The “event-driven” entry for Okta is our reading of how Okta pushes changes when they occur; Okta’s SCIM reference does not state a fixed cadence, so treat it as unverified for timing guarantees.

Matching rules and why duplicates appear

Duplicates come from matching mismatches. Suppose the customer configured Entra to match on userName and the user’s UPN changed from ada.l@corp.example to ada.lovelace@corp.example. Entra will still find the user by externalId, which is a reason to index and match on it. If you only filter by userName, Entra’s next cycle finds nothing, attempts a create, and either gets a 409 because the email is unique elsewhere in your model or succeeds and creates a second account.

The worked fix is to treat externalId as the strongest per-tenant link once set, userName as a mutable attribute and email as data. When a create arrives with an externalId that already exists in the tenant, respond with 409 and let the IdP re-match, rather than silently creating a twin.

Trade-offs, Gotchas, and What Goes Wrong

Deprovisioning automation fan-out when SCIM sets active false

Figure 4: What a single active-false PATCH should trigger. Session and token revocation happen before the response; downstream cleanup runs through a retried queue, and a reconciliation job catches anything the push missed.

Push is lossy without reconciliation. SCIM provisioning is fire-and-forget from the IdP’s perspective. If your endpoint returns 500 during an outage, Okta and Entra retry by their own policies and may eventually mark the operation failed and wait for an admin. Run a periodic reconciliation that compares your active users against the last state each IdP sent, and flag users who have not been touched by SCIM for an unusually long time in tenants where SCIM is enabled. RFC 9967’s Security Event Tokens point towards a better future with asynchronous change notifications, but adoption by major IdPs is not something we could confirm at the time of writing.

Deactivation latency is the real security metric. Measure the time from the PATCH arriving to the last session being dead, not just the HTTP response time. Long-lived refresh tokens, cached authorisation decisions and WebSocket connections are typical survivors. A user whose access token is valid for an hour after deactivation was not deprovisioned; they were scheduled for deprovisioning.

SCIM-managed users need a lock. Once a tenant enables SCIM, block your own UI from editing attributes the IdP owns, or the next PUT will silently overwrite them. Allow local edits only to product-specific fields that SCIM does not map.

Mixing JIT and SCIM creates twins. If SSO JIT creates a user before SCIM’s first sync, SCIM’s create will collide. Either disable JIT for SCIM tenants or make JIT link by the same matching keys SCIM will use.

Group display names are not identifiers. Entra matches groups by displayName and requires uniqueness, so a renamed group can look like a new one. Store and link by id and externalId, and treat renames as updates.

Large initial syncs are bursty. A first sync for a 40,000-person customer arrives as tens of thousands of searches and creates. As an illustrative calculation, at two requests per user and 20 milliseconds of server time per request, that is roughly 1,600 seconds of serial work, before membership PATCHes. Size connection pools and rate limits for that burst, and return 429 with Retry-After rather than timing out.

Tenant isolation bugs are catastrophic. A filter that forgets its tenant predicate exposes another customer’s directory. Put the tenant in every query by construction, for example through row-level security, and add a test that runs every endpoint with a token from a different tenant.

Security of the SCIM endpoint itself. The SCIM API can create administrators in your product if groups map to admin roles. Treat its credentials like production database credentials. Log every request, alert on unusual volumes of deactivations or role-granting group changes, and give customers an audit view of what their IdP changed. Workload identity for internal callers matters too; our comparison of Kubernetes Pod Certificates, SPIFFE/SPIRE and cert-manager covers how the revocation workers behind your SCIM API should authenticate to the services they call.

Practical Recommendations

Start your SCIM provisioning work with the narrowest correct server and widen it with evidence. The core that Okta and Entra both need is small: Users and Groups with create, read, search by eq and and, PATCH, PUT for Okta, delete, discovery endpoints and per-tenant authentication. Bulk, sorting, or filters and /Me can wait until a customer needs them.

Spend your effort on what the protocol does not specify: what deactivation actually revokes, how groups map to roles, how you reconcile missed events and how you show customers what happened. Those decide whether security reviewers trust your scim provisioning, and they are also where competitors’ implementations are weakest.

Build the PATCH engine against recorded payloads from both Entra dialects and from Okta before you build anything else. It is the component that fails in production most often, and the failures are silent: an operation that returns 200 but changes nothing looks like success to the IdP.

Finally, make SCIM a first-class part of the product. Give admins a setup page that issues and rotates tokens, displays the base URL, shows the last request time and lists recent errors. A customer who can see that the last Entra call failed with invalidFilter will fix their mapping without a support ticket.

Checklist

  • [ ] Tenant derived from the credential; every query and unique index includes tenant ID.
  • [ ] userName unique and case-insensitive per tenant; externalId indexed.
  • [ ] Empty search returns 200 with an empty ListResponse; paging fields are integers with stable order.
  • [ ] PATCH accepts capitalised ops, string booleans, path-less values and filtered member paths, atomically.
  • [ ] active: false kills sessions and tokens before the response returns.
  • [ ] Groups map to roles through a customer-editable mapping; deltas are idempotent.
  • [ ] ETags returned; If-Match honoured with 412.
  • [ ] Reconciliation job and per-tenant SCIM audit log in place.
  • [ ] Validated with Microsoft’s and Okta’s tooling plus a recorded replay suite.

Frequently Asked Questions

What is SCIM provisioning and how is it different from SSO?

SCIM provisioning is the automated creation, update and removal of user accounts and group memberships in an application, driven by the customer’s identity provider over a standard REST API defined in RFC 7643 and RFC 7644. SSO protocols such as SAML and OpenID Connect only authenticate a user at sign-in. SSO tells you who is logging in now; SCIM tells you who should have an account at all, including people who have left. Most enterprise buyers expect both, because SSO alone cannot remove access for someone who never logs in again.

Do Okta and Microsoft Entra ID delete users through SCIM?

Mostly they deactivate. Okta’s SCIM reference states that Okta never sends DELETE for users; deprovisioning sets active to false, and suspending a user in Okta sends nothing. Microsoft’s guidance for Entra treats PATCH active=false as a soft delete that must keep the user queryable, and reserves DELETE /Users/{id} for hard deletion when a user is removed from the application. Your service should therefore treat active: false as the moment to revoke access, and treat DELETE as a separate data-retention event handled according to the customer’s contract.

Which SCIM 2.0 API endpoints are required for a B2B SaaS app?

In practice you need /Users and /Groups with POST, GET by id, GET with a filter, PATCH, PUT and DELETE, plus the discovery endpoints /ServiceProviderConfig, /ResourceTypes and /Schemas. Microsoft notes that Entra supports groups only if you support PATCH, and that it does not use /Bulk. Okta uses PUT for most user updates in integration network apps, so PUT is not optional if you want Okta certification. Endpoints such as /Me, /Bulk and POST /.search are defined in RFC 7644 but rarely required by the major IdPs.

How should a SCIM server handle PATCH requests from Entra ID?

Accept both dialects. By default Entra sends capitalised op values such as Replace, sends active as the string "False" and removes group members with path: "members" and a value array. With the aadOptscim062020 flag in the tenant URL, it sends lower-case ops, real booleans, combined path-less replaces and filtered member paths. Microsoft says the flag does not apply to on-demand provisioning, so you can receive both forms from one tenant. Normalise op casing, coerce string booleans and treat a value-array remove as removing only the listed members.

How fast does SCIM deprovisioning happen?

It depends on the IdP and on your service. Microsoft documents that Entra’s provisioning cycle runs roughly every 40 minutes, so a disabled user may reach you up to a cycle later unless an admin uses on-demand provisioning. Okta pushes changes as they happen, though we could not find a documented latency guarantee. On your side, the PATCH should not return until sessions and tokens are revoked. Measure end-to-end deactivation latency from directory change to last valid token, because that is the number auditors and security teams care about.

What changed in SCIM recently?

The IETF SCIM working group published RFC 9865 in October 2025, adding optional cursor-based pagination with cursor, nextCursor and previousCursor and new pagination settings in ServiceProviderConfig. In May 2026 it published RFC 9944 for device schema extensions and RFC 9967, a SCIM profile for Security Event Tokens covering provisioning events and asynchronous requests. RFCs 9865 and 9967 update the 2015 core RFCs. Several individual drafts, including roles and entitlements, tenant resources and AI agent resources, are in progress but are not standards.

Further Reading

By Riju — about

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *