Identity Azure

Building a SCIM 2.0 Provisioning Endpoint and Integrating It with Entra ID Automatic Provisioning

If you run a SaaS or an internal platform that enterprises consume, sooner or later a customer’s IT team will ask: “Do you support SCIM?” What they mean is they want Entra ID (or Okta, or anything else) to push their joiners, movers, and leavers into your app automatically. This guide builds a compliant SCIM 2.0 endpoint for Users and Groups, then connects it to Entra ID’s automatic provisioning with the attribute mappings, scoping, and deprovisioning behavior that survive a real production rollout.

1. SCIM 2.0 protocol essentials

SCIM (System for Cross-domain Identity Management) 2.0 is defined by RFC 7643 (schema) and RFC 7644 (protocol). It is a REST API over JSON with a fixed set of resources and conventions. The three things you must get right before writing any handler are the resource shapes, the discovery endpoints, and the media type.

Every SCIM resource carries a schemas array, an immutable id assigned by your service, and a meta block. The canonical User looks like this:

{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
  "id": "8a4f2c1e-0b6d-4a2a-9b1c-2f0e7d9a3c11",
  "externalId": "0a8b1c2d3e",
  "userName": "ada@contoso.com",
  "name": { "givenName": "Ada", "familyName": "Lovelace" },
  "emails": [{ "value": "ada@contoso.com", "type": "work", "primary": true }],
  "active": true,
  "meta": {
    "resourceType": "User",
    "created": "2026-04-22T10:00:00Z",
    "lastModified": "2026-04-22T10:00:00Z",
    "location": "https://scim.example.com/scim/v2/Users/8a4f2c1e-0b6d-4a2a-9b1c-2f0e7d9a3c11"
  }
}

A note on identifiers that trips up almost everyone: id is yours, opaque and immutable. externalId is the client’s identifier for the same object. userName is the unique login handle. Entra ID matches existing objects on userName by default, so treat it as a unique key.

SCIM also mandates discovery endpoints. Entra ID does not strictly require all three, but a compliant service exposes:

Endpoint Purpose
GET /ServiceProviderConfig Advertises supported features: PATCH, filtering, bulk, sort, etag, auth schemes
GET /ResourceTypes Lists User and Group resource types and their endpoints
GET /Schemas Returns full attribute definitions for each schema

The media type for SCIM bodies is application/scim+json. Accept application/json on input for tolerance, but emit application/scim+json. A minimal 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 },
  "changePassword": { "supported": false },
  "sort": { "supported": false },
  "etag": { "supported": false },
  "authenticationSchemes": [{
    "type": "oauthbearertoken",
    "name": "OAuth Bearer Token",
    "description": "Authentication via the OAuth Bearer Token Standard",
    "primary": true
  }]
}

Advertise only what you actually implement. If patch.supported is true but your PATCH handler is broken, Entra ID will send PATCH and your sync will silently fail. Honesty in ServiceProviderConfig is operationally cheaper than a half-working feature flag.

2. Implementing the core User endpoints

The lifecycle Entra ID exercises is: create on assignment, read to reconcile, PATCH to update, and PATCH active: false to deprovision. Build these five handlers.

POST /Users creates a resource. Assign a server id, persist, and respond 201 Created with the full object and a Location header. If userName already exists, you must return 409 Conflict with scimType: "uniqueness" so the connector switches to an update path instead of looping.

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

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

GET /Users/{id} returns one resource or 404. GET /Users supports filtering and pagination. Entra ID’s most common query is an equality filter on userName to find an existing object before deciding create vs. update:

GET /scim/v2/Users?filter=userName eq "ada@contoso.com"&startIndex=1&count=100

You only need to support eq on userName and externalId for the Entra ID happy path, but parse the filter defensively. List responses are wrapped in a ListResponse:

{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
  "totalResults": 1,
  "startIndex": 1,
  "itemsPerPage": 1,
  "Resources": [ { "id": "8a4f2c1e-...", "userName": "ada@contoso.com" } ]
}

Note startIndex is 1-based, not 0-based. Returning a 0-based index is one of the most common interop bugs and causes Entra ID to skip or duplicate the first record on each page.

PATCH /Users/{id} applies partial updates (covered in detail below). DELETE /Users/{id} is where you make a design decision. SCIM defines DELETE as a hard delete returning 204 No Content, but Entra ID’s default deprovisioning action is to send PATCH active: false (a soft delete), not DELETE. So implement DELETE for compliance, but expect the disable path to dominate. Persist a deleted or active flag rather than destroying rows; you will want the audit trail and the ability to reactivate.

A pragmatic Express handler sketch:

app.post("/scim/v2/Users", async (req, res) => {
  const { userName } = req.body;
  if (await store.findByUserName(userName)) {
    return res.status(409).json(scimError("uniqueness", "userName already exists", 409));
  }
  const user = await store.createUser({
    ...req.body,
    id: crypto.randomUUID(),
    active: req.body.active ?? true,
  });
  res.status(201)
     .location(`${BASE_URL}/Users/${user.id}`)
     .type("application/scim+json")
     .json(toScimUser(user));
});

3. Handling PATCH operations correctly

PATCH is where most SCIM implementations break, and it is also the operation Entra ID leans on hardest. A PATCH body uses the PatchOp message with an Operations array. Each operation has an op (add, replace, remove), an optional path, and a value.

The subtle parts:

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

For group membership, Entra ID adds members like this. Note the path filter syntax for removal, which targets a specific member by value:

{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
  "Operations": [
    {
      "op": "add",
      "path": "members",
      "value": [{ "value": "8a4f2c1e-0b6d-4a2a-9b1c-2f0e7d9a3c11" }]
    },
    {
      "op": "remove",
      "path": "members[value eq \"7c3e1b0a-...\"]"
    }
  ]
}

Your PATCH engine therefore needs three behaviors for members: append on add, remove a single member matched by the value eq filter, and replace the entire collection when op is replace with path: "members". Make add idempotent. Entra ID retries on transient failures, so adding a member who is already present must be a no-op that still returns success, not a duplicate or an error.

function applyPatch(resource, ops) {
  for (const raw of ops) {
    const op = raw.op.toLowerCase();
    if (op === "replace" && !raw.path) {
      Object.assign(resource, raw.value);     // merge top-level attrs
    } else if (raw.path === "active") {
      resource.active = raw.value;
    } else if (raw.path === "members" && op === "add") {
      const existing = new Set(resource.members.map(m => m.value));
      for (const m of raw.value) {
        if (!existing.has(m.value)) resource.members.push({ value: m.value });
      }
    } else if (op === "remove" && raw.path?.startsWith("members[")) {
      const id = parseMemberFilter(raw.path);  // extract value eq "..."
      resource.members = resource.members.filter(m => m.value !== id);
    }
  }
  resource.meta.lastModified = new Date().toISOString();
  return resource;
}

Respond to a successful PATCH with 200 OK and the updated resource (or 204 No Content if you advertise that, but returning the body is safer for connector reconciliation).

4. Authentication and securing the endpoint

Entra ID supports two auth schemes for SCIM: a long-lived bearer token you paste into the provisioning config, or OAuth 2.0 authorization code grant against your own authorization server.

For most integrations the long-lived bearer token is the pragmatic choice. Generate a high-entropy token, store its hash, and validate on every request:

app.use("/scim/v2", (req, res, next) => {
  const header = req.get("authorization") || "";
  const token = header.replace(/^Bearer\s+/i, "");
  if (!token || !timingSafeEqualHash(token, EXPECTED_TOKEN_HASH)) {
    return res.status(401)
      .set("WWW-Authenticate", "Bearer")
      .json(scimError(null, "Unauthorized", 401));
  }
  next();
});

Hardening that actually matters:

If your security posture mandates short-lived credentials, configure the OAuth code grant in the provisioning UI instead, registering Entra ID as a client of your IdP. It is more moving parts; reach for it when policy requires it, not by default.

5. Registering the SCIM app in Entra ID and the test connection

Create a gallery or non-gallery enterprise application, then configure provisioning. The portal flow is well known, but the same can be driven via Microsoft Graph. The key object is the synchronization resource on the service principal.

In the portal: Entra ID > Enterprise applications > New application > Create your own application > Integrate any other application you don’t find in the gallery. Then open Provisioning, set Provisioning Mode to Automatic, and fill in Tenant URL and Secret Token.

Click Test Connection. Entra ID performs a real handshake: it authenticates with the token and issues a probe request (typically a filtered GET /Users for a random userName that will not exist, expecting an empty ListResponse, plus reading your schema). If this fails, the error names the failing request. The usual culprits:

Test Connection failing on the GET probe is almost never an auth problem if the token validated. It is your GET /Users?filter=... handler. Hit it yourself with curl -H "Authorization: Bearer <token>" "https://scim.example.com/scim/v2/Users?filter=userName%20eq%20%22nobody%22" and confirm you get 200 with totalResults: 0.

6. Designing attribute mappings, expressions, and scoping filters

Under Provisioning > Mappings there are two flows: Provision Microsoft Entra ID Users and Provision Microsoft Entra ID Groups. Each maps source (Entra) attributes to your SCIM target attributes.

The mappings that matter most:

Source (Entra ID) Target (SCIM) Matching Notes
userPrincipalName userName Precedence 1 Primary matching key; keep it unique
Switch([IsSoftDeleted]...) active Drives enable/disable
mail emails[type eq "work"].value Multi-valued, use type filter
givenName name.givenName
surname name.familyName
objectId externalId Precedence 2 (optional) Stable secondary match

Two mapping techniques to know:

Expressions. The mapping editor supports an expression language. The default active mapping uses a Switch on IsSoftDeleted so that soft-deleted or unassigned users map to active: false. You can also use Join, Replace, and Mid to reshape values, for example constructing a userName from Join("@", mailNickname, "example.com").

Matching attributes. A mapping marked as a matching attribute is how Entra ID decides whether an object already exists in your app. Assign Matching precedence 1 to userName. Optionally add externalId/objectId as precedence 2 so a renamed UPN still reconciles to the right target via the immutable object id.

Scoping filters. Under Settings, the default Scope is “Sync only assigned users and groups,” which means provisioning is driven by app assignments. If you choose “Sync all users and groups,” constrain it with a scoping filter so you do not push the entire directory:

Attribute: department   Operator: EQUALS   Value: Engineering
Attribute: accountEnabled Operator: EQUALS Value: true

Objects that fail the scoping filter are treated as out of scope and, if previously provisioned, get deprovisioned. That is a feature, but it surprises people, so understand it before flipping the scope.

7. Deprovisioning and lifecycle: disable vs. delete

This is the section that determines whether your integration is safe to leave running. Entra ID deprovisions an object when it is unassigned, soft-deleted, or falls out of a scoping filter. The default action is disable, sent as PATCH active: false. Hard DELETE is only sent after the object is permanently deleted in Entra ID, and even then the default skips it unless configured.

Decide your semantics deliberately:

Under Provisioning > Settings there is a behavior controlling what happens when a user goes out of scope (“Skip out-of-scope deletions” toggle). With it off (default), out-of-scope users are deprovisioned. Turning it on prevents accidental mass-disables when you change a scoping filter, which is exactly the kind of change that causes an incident at 2 a.m.

Reconciling drift. Entra ID runs an incremental sync roughly every 40 minutes after the initial cycle, using a watermark so it only sends changes. Drift creeps in when changes are made directly in your app, or when a sync error leaves an object half-updated. To force a clean reconciliation, use Restart provisioning (or Clear current state and restart synchronization), which discards the watermark and re-evaluates every in-scope object. Do this after fixing a mapping bug; otherwise corrected objects will not re-sync until they change again on the source side.

Provisioning > (Stop) > Restart provisioning
-> next cycle is a full sync, not incremental

Enterprise scenario

A platform team shipped SCIM for their B2B SaaS and onboarded a 22,000-seat customer. The initial cycle ran clean, then two weeks later the customer’s helpdesk reported a wave of users locked out overnight. The provisioning logs showed thousands of Disable actions with reason “Skipping export: user is not in scope.” Nobody had touched assignments.

Root cause: the customer’s IT admin had switched the app’s Scope from “Sync only assigned users and groups” to “Sync all users and groups” and added a scoping filter on department EQUALS Engineering. Every account whose department was blank or differently cased fell out of scope, and with Skip out-of-scope deletions off (the default), Entra ID dutifully sent PATCH active: false for each one. The endpoint was behaving correctly; it was honoring exactly what the connector sent.

The fix had two parts. First, stop the bleed: enable the guardrail so out-of-scope objects are never auto-disabled, then restart with a full reconciliation so the wrongly disabled users flip back to active: true.

Provisioning > Settings:
  Skip out-of-scope deletions = Yes
Provisioning > (Stop) > Restart provisioning   # full sync, re-evaluates every in-scope object

Second, they made the endpoint defensive against the gap that turned a config mistake into an outage: any single cycle reporting more than 5% of the in-scope population as Disable now trips a Log Analytics alert and the on-call pauses the job before export. The lesson is that “Skip out-of-scope deletions” is not optional polish for a large tenant. Leave it on by default and treat any scope or filter change as a staged operation validated through Provision on demand first.

Verify

Validate the endpoint independently of Entra ID first, then confirm end to end.

TOKEN="<your-bearer-token>"
BASE="https://scim.example.com/scim/v2"

# Discovery
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/ServiceProviderConfig" | jq .

# Create
curl -s -X POST "$BASE/Users" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/scim+json" \
  -d '{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],
       "userName":"ada@contoso.com","active":true,
       "name":{"givenName":"Ada","familyName":"Lovelace"}}' | jq .

# Filter (the Test Connection probe shape) -> expect totalResults: 0
curl -s -H "Authorization: Bearer $TOKEN" \
  "$BASE/Users?filter=userName%20eq%20%22nobody@contoso.com%22" | jq '.totalResults'

# Disable via PATCH
curl -s -X PATCH "$BASE/Users/<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 '.active'

Then, in Entra ID: run Provision on demand for a single test user (Provisioning > Provision on demand), which shows each step (import, match, determine actions, export) with the exact payload sent. This is the fastest feedback loop. Finally, read the provisioning logs (Enterprise app > Provisioning logs, also queryable via GET /servicePrincipals/{id}/synchronization/jobs/{jobId}/... in Graph or surfaced in Log Analytics) and confirm Status = Success with no skipped or failed entries.

Operating at scale

A SCIM endpoint that works for 10 users can fall over at 50,000. Build for the cycle behavior up front.

Checklist

Pitfalls

Build the endpoint to be honest about its capabilities, idempotent on every write, and 1-based on pagination, and the Entra ID side becomes the easy part. The hard-won lesson is that SCIM failures are almost always quiet, so the logs and on-demand preview are your real test harness, not a one-time green check.

Entra IDSCIMProvisioningREST APILifecycle

Comments

Keep Reading