Stop Writing Sync Scripts: SCIM 2.0 Directory Provisioning End to End

Every company eventually builds the script. It starts innocent — a cron job, maybe 80 lines of Python, pulling from Active Directory and pushing to Slack via the API. Six months later it’s 600 lines, has three different auth strategies, silently swallows errors, and the last person who understood it left in March. When a user gets terminated, their accounts stay active for two weeks because nobody noticed the script was failing. Then the auditors show up.

This is the ad-hoc provisioning trap, and nearly everyone falls into it before discovering SCIM.

SCIM — System for Cross-domain Identity Management — is the HTTP-based protocol that was specifically designed to replace that script. It’s been around since 2015 (RFC 7643 and 7644), most major identity providers support it out of the box, and yet a surprising number of engineering teams still haven’t wired it up properly end to end.

This article walks through the full picture: the protocol internals, building a compliant SCIM server, configuring an IdP (Okta and Azure AD examples), attribute mapping, and the parts that will bite you in production if you skip them.


Why JIT Provisioning Isn’t Enough

Before diving in, let’s kill a common misconception. A lot of teams lean on Just-in-Time provisioning — the pattern where a user account gets created in your app on their first SSO login. It’s simple, it works for the initial account creation case, and it requires zero infrastructure beyond your existing SAML/OIDC setup.

The problem is deprovisioning. JIT has no mechanism for it. When an employee leaves, their SSO session expires, but their account in every downstream application still exists, often with whatever data and permissions it accumulated. SCIM solves this because the IdP pushes changes — including deactivation and deletion — to every connected app the moment the change happens in the directory.

SCIM also handles the full lifecycle: account creation before first login, group membership changes, attribute updates like job title or department, and suspension without deletion (important for compliance reasons). JIT handles exactly one event. SCIM handles all of them.


The Protocol in Plain Terms

SCIM 2.0 is a REST API with a defined schema. Your identity provider acts as the client; your application exposes the server. The IdP makes HTTP calls to manage resources.

Core resources you’ll implement:

  • /Users — individual user accounts
  • /Groups — group memberships

Supporting endpoints the IdP expects to discover:

  • GET /ServiceProviderConfig — tells the IdP what features your server supports (PATCH, bulk, filtering, etc.)
  • GET /Schemas — your supported attribute schemas
  • GET /ResourceTypes — describes User and Group endpoints

The data model uses URN-namespaced schemas. A standard user object looks like this:

{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
  "id": "7d1d2b41-0c22-4f5a-91b3-9e2a3c4b5d6e",
  "externalId": "okta-00u1ab2cd3EF",
  "userName": "[email protected]",
  "name": {
    "givenName": "John",
    "familyName": "Smith",
    "formatted": "John Smith"
  },
  "emails": [
    {
      "value": "[email protected]",
      "primary": true,
      "type": "work"
    }
  ],
  "active": true,
  "meta": {
    "resourceType": "User",
    "created": "2025-11-01T08:00:00Z",
    "lastModified": "2025-11-01T08:00:00Z",
    "location": "https://app.example.com/scim/v2/Users/7d1d2b41-0c22-4f5a-91b3-9e2a3c4b5d6e"
  }
}

Notice id vs externalId. The id is your application’s internal identifier for the user. The externalId is the IdP’s identifier. Both matter and both need to be stored — the IdP uses your id to address future updates to that user.


Building a Minimal SCIM 2.0 Server

Let’s build a functional SCIM endpoint in Python. This will be the core that any IdP can talk to. I’m using Flask here for readability; the same logic maps to FastAPI, Express, Go, or whatever you’re running.

# scim_server.py
# Minimal SCIM 2.0 server — handles Users and Groups

from flask import Flask, request, jsonify, abort
import uuid
from datetime import datetime, timezone

app = Flask(__name__)

# In production this is your database. Here it's in-memory for clarity.
users_db = {}

SCIM_SCHEMAS_USER = "urn:ietf:params:scim:schemas:core:2.0:User"
SCIM_SCHEMAS_LIST = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
SCIM_SCHEMAS_ERROR = "urn:ietf:params:scim:api:messages:2.0:Error"


def scim_error(status_code, detail, scim_type=None):
    body = {
        "schemas": [SCIM_SCHEMAS_ERROR],
        "status": str(status_code),
        "detail": detail,
    }
    if scim_type:
        body["scimType"] = scim_type
    return jsonify(body), status_code


def user_to_scim(user):
    """Convert internal user dict to SCIM User representation."""
    return {
        "schemas": [SCIM_SCHEMAS_USER],
        "id": user["id"],
        "externalId": user.get("externalId"),
        "userName": user["userName"],
        "name": user.get("name", {}),
        "emails": user.get("emails", []),
        "active": user.get("active", True),
        "meta": {
            "resourceType": "User",
            "created": user["created"],
            "lastModified": user["lastModified"],
            "location": f"{request.host_url}scim/v2/Users/{user['id']}",
        },
    }


@app.route("/scim/v2/ServiceProviderConfig", methods=["GET"])
def service_provider_config():
    return jsonify({
        "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": False},
        "authenticationSchemes": [
            {
                "name": "OAuth Bearer Token",
                "description": "Authentication using a Bearer token",
                "type": "oauthbearertoken",
                "primary": True,
            }
        ],
    })


@app.route("/scim/v2/Users", methods=["GET"])
def list_users():
    # SCIM filter support — Okta and Azure AD both send ?filter=userName eq "..."
    # for existence checks before creating a new user. You MUST implement this.
    filter_param = request.args.get("filter", "")
    start_index = int(request.args.get("startIndex", 1))
    count = int(request.args.get("count", 100))

    results = list(users_db.values())

    if filter_param:
        # Minimal filter: only handle "userName eq <value>"
        if 'userName eq' in filter_param:
            username = filter_param.split('"')[1]
            results = [u for u in results if u["userName"] == username]

    paginated = results[start_index - 1 : start_index - 1 + count]

    return jsonify({
        "schemas": [SCIM_SCHEMAS_LIST],
        "totalResults": len(results),
        "startIndex": start_index,
        "itemsPerPage": len(paginated),
        "Resources": [user_to_scim(u) for u in paginated],
    })


@app.route("/scim/v2/Users", methods=["POST"])
def create_user():
    data = request.get_json()
    if not data or "userName" not in data:
        return scim_error(400, "userName is required", "invalidValue")

    # Idempotency: check if user already exists by userName
    existing = next((u for u in users_db.values()
                     if u["userName"] == data["userName"]), None)
    if existing:
        return scim_error(409, f"User {data['userName']} already exists", "uniqueness")

    now = datetime.now(timezone.utc).isoformat()
    user_id = str(uuid.uuid4())
    user = {
        "id": user_id,
        "externalId": data.get("externalId"),
        "userName": data["userName"],
        "name": data.get("name", {}),
        "emails": data.get("emails", []),
        "active": data.get("active", True),
        "created": now,
        "lastModified": now,
    }
    users_db[user_id] = user

    return jsonify(user_to_scim(user)), 201


@app.route("/scim/v2/Users/<user_id>", methods=["GET"])
def get_user(user_id):
    user = users_db.get(user_id)
    if not user:
        return scim_error(404, f"User {user_id} not found")
    return jsonify(user_to_scim(user))


@app.route("/scim/v2/Users/<user_id>", methods=["PUT"])
def replace_user(user_id):
    user = users_db.get(user_id)
    if not user:
        return scim_error(404, f"User {user_id} not found")

    data = request.get_json()
    now = datetime.now(timezone.utc).isoformat()

    user.update({
        "userName": data.get("userName", user["userName"]),
        "name": data.get("name", user.get("name", {})),
        "emails": data.get("emails", user.get("emails", [])),
        "active": data.get("active", user.get("active", True)),
        "externalId": data.get("externalId", user.get("externalId")),
        "lastModified": now,
    })
    users_db[user_id] = user
    return jsonify(user_to_scim(user))


@app.route("/scim/v2/Users/<user_id>", methods=["PATCH"])
def patch_user(user_id):
    user = users_db.get(user_id)
    if not user:
        return scim_error(404, f"User {user_id} not found")

    data = request.get_json()
    operations = data.get("Operations", [])
    now = datetime.now(timezone.utc).isoformat()

    for op in operations:
        op_type = op.get("op", "").lower()
        path = op.get("path", "")
        value = op.get("value")

        if op_type == "replace":
            if path == "active":
                user["active"] = value
            elif path == "userName":
                user["userName"] = value
            elif not path and isinstance(value, dict):
                # No path means replace multiple top-level attributes
                for k, v in value.items():
                    if k in ("userName", "name", "emails", "active", "externalId"):
                        user[k] = v

    user["lastModified"] = now
    users_db[user_id] = user
    return jsonify(user_to_scim(user))


@app.route("/scim/v2/Users/<user_id>", methods=["DELETE"])
def delete_user(user_id):
    if user_id not in users_db:
        return scim_error(404, f"User {user_id} not found")
    del users_db[user_id]
    return "", 204


if __name__ == "__main__":
    app.run(port=8443, debug=False)

This is functional enough to pass validation from Okta and Azure AD. Before wiring up a real IdP, test it with curl:

# Create a user
curl -s -X POST https://cd-linux.club:8443/scim/v2/Users \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-token" \
  -d '{
    "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
    "userName": "[email protected]",
    "name": {"givenName": "John", "familyName": "Smith"},
    "emails": [{"value": "[email protected]", "primary": true}],
    "active": true
  }' | jq .

# Deactivate (this is how offboarding works — PATCH active=false)
curl -s -X PATCH https://cd-linux.club:8443/scim/v2/Users/<id> \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-token" \
  -d '{
    "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
    "Operations": [{"op": "replace", "path": "active", "value": false}]
  }' | jq .

# Filter by userName (the existence-check Okta runs before creating)
curl -s "https://cd-linux.club:8443/scim/v2/Users?filter=userName+eq+%[email protected]%22" \
  -H "Authorization: Bearer your-token" | jq .

Wiring Up Okta

In Okta, SCIM provisioning lives under an Application. If you’re building a custom integration, create a new app in the Okta Integration Network using the "SWA" or custom app path, then enable SCIM.

Steps:

  1. In Okta Admin, go to Applications → Create App Integration.
  2. Pick SWA or use the API Service type for machine-to-machine.
  3. Under the Provisioning tab, select SCIM as the provisioning method.
  4. Set the SCIM connector base URL to your server: https://app.example.com/scim/v2
  5. Set Unique identifier field for users: userName
  6. Enable the operations you want: Push New Users, Push Profile Updates, Push Groups, Deactivate Users
  7. Set Authentication Mode to HTTP Header, put your token in the Bearer field
  8. Click Test Connector Configuration — Okta will hit /ServiceProviderConfig and run a basic GET on /Users

For Azure AD (Entra ID), the path is: Enterprise Applications → New Application → Create your own → Integrate any other application → Provisioning → Automatic → Admin credentials. Same fields, same behavior.


Gotcha: The Filter That Breaks Everything

Every SCIM client runs a GET /Users?filter=userName eq "..." before creating a new user to check for duplicates. If your server returns a 501 or an empty response when it shouldn’t, you’ll end up with duplicate accounts or creation failures.

Your filter implementation needs to handle at minimum:

  • userName eq "value"
  • externalId eq "value"
  • id eq "value"

Some IdPs also send emails[type eq "work" and value eq "..."] — that’s the complex filter syntax from RFC 7644. You don’t have to implement the full spec on day one, but you need to handle unknown filters gracefully by returning an empty ListResponse rather than erroring out.


Gotcha: PATCH is Where Implementations Fall Apart

The SCIM PATCH operation (PatchOp schema) is the most underspecified part of the protocol in practice. IdPs vary wildly in how they use it. Here’s what you’ll see in the wild:

Okta sends this to deactivate a user:

{"Operations": [{"op": "replace", "path": "active", "value": false}]}

Azure AD sends this for the same thing:

{"Operations": [{"op": "Replace", "value": {"active": false}}]}

Notice "Replace" with a capital R and no path. Both are technically valid per the spec. Your PATCH handler needs to deal with both forms. Check op.lower() and handle the case where path is absent and value is an object.

Also watch for group membership PATCH operations — when Azure AD adds a user to a group it sends:

{
  "op": "add",
  "path": "members",
  "value": [{"value": "<user-id>", "display": "[email protected]"}]
}

The value inside value is your internal user id. If you haven’t implemented Groups yet, every group sync will fail silently or generate 400 errors that are hard to trace.


Attribute Mapping: The Silent Data Problem

Your IdP has fields with IdP names. Your app has fields with your names. SCIM handles the transport, but attribute mapping is on you.

Both Okta and Azure AD let you configure custom attribute mappings. For Okta, under the app’s Profile Editor, you define which SCIM attribute maps to which Okta profile attribute. For Azure AD, it’s under Provisioning → Mappings → Provision Azure Active Directory Users.

Common mismatches to plan for:

  • displayName vs name.formatted — IdPs populate these inconsistently
  • emails[type eq "work"].value vs just emails[0].value — always check primary: true rather than array index
  • active vs suspended — your app might have a suspension concept that maps to active: false
  • Department, cost center, manager — these live in the Enterprise User Extension schema (urn:ietf:params:scim:schemas:extension:enterprise:2.0:User) and many apps forget to declare support for it in /Schemas

Declare your schemas accurately in the /Schemas endpoint. If you claim to support an attribute you silently ignore, you’ll end up with phantom mapping configs in the IdP that never sync correctly.


Production Hardening

Authentication. Bearer tokens are the standard. Generate a long, random token (32+ bytes, base64 encoded), store it hashed in your config, and validate it on every request before doing anything else. Rotate it on a schedule. Never put it in a URL parameter — some IdPs support it but every load balancer on the planet will log it.

Rate limiting and timeouts. The IdP may push hundreds of changes in a short window after initial setup or a large import. Your SCIM endpoint needs to handle concurrent requests and should return 429 with a Retry-After header rather than queuing indefinitely.

Idempotency. POST to /Users should return 409 Conflict if the user already exists. The IdP will retry failed requests — if POST isn’t idempotent, you’ll get duplicates.

Audit log every write. SCIM events are your paper trail for access reviews and compliance. Log the operation, the user ID, the source IP, the externalId, and the timestamp. The deprovisioning audit ("when was this account disabled and by what system") is exactly what an auditor will ask for.

Soft delete vs hard delete. When the IdP sends DELETE /Users/{id}, most production systems should deactivate the account (active: false) rather than destroy the record. Map DELETE to a "suspended" state and provide a separate admin process for actual deletion. GDPR and HR both have opinions about this.

Validate your schema. There’s a free SCIM validator at https://scimvalidator.microsoft.com (from Microsoft, works against any server). Run it before connecting a production IdP. It’ll find the edge cases your manual curl tests missed.


Open-Source SCIM Servers Worth Knowing

If you don’t want to roll your own, these are the credible options:

  • Authentik — self-hosted IdP with SCIM support in both directions. It can act as a SCIM source (push from Authentik to apps) or as a SCIM destination (receive from upstream IdPs). This is the cleanest option for self-hosted stacks.
  • SCIM 2.0 Test Server — a sandbox endpoint maintained by the community for testing IdP-side configs without standing up your own server.
  • go-scim — Go library with a complete SCIM 2.0 implementation including the complex filter expression parser.
  • python-scim — lighter Python option, useful as a reference for the data models.

The Operational Reality

After the initial setup, the thing you’ll spend the most time on is debugging provisioning errors. Both Okta and Azure AD have provisioning logs built into the UI — use them. Okta’s are under Applications → Provisioning → Logs; Azure AD’s are under Provisioning logs in the Entra ID portal. They show every request, the response code, and the parsed error detail.

Set up alerting on 4xx and 5xx responses from your SCIM endpoint. A silent provisioning failure means a new hire can’t log in on their first day, or an ex-employee’s account stays active. Neither is acceptable and neither will announce itself loudly unless you instrument it.

The protocol is stable. The schema negotiation, the filter handling, the PATCH inconsistencies — these are the parts that take time to get right. But once they’re wired up, you genuinely never touch that code again. The IdP owns the sync. Your job is to keep the endpoint healthy.

That’s a much better deal than maintaining the script.

👁 Views: 112,656 · Unique visitors: 45,393