Skip to main content
Articles

SCIM vs JIT provisioning: when to use each - Part 2

Author: Roy Anger
Published: (last updated )

SCIM vs JIT provisioning: when should I use each? - Part 2

Part 1 covered the core concepts, the deprovisioning gap, and a framework for choosing between JIT and SCIM. Part 2 covers the implementation reality: what each side actually requires, the per-IdP gotchas that bite in production, the build-versus-buy decision, and how authentication providers supply provisioning.

Implementation considerations and common gotchas

The conceptual difference is clean. The implementations are where the surprises live.

Implementing JIT provisioning

JIT implementation is mostly attribute mapping. You read identity from the assertion and create or update the record. Two practices save pain later: match users on a stable identifier such as the SAML NameID or OIDC sub rather than on email (emails change), and decide deliberately whether to re-sync attributes on every login or only at creation.

Role mapping via the assertion is the brittle part. It relies on exact-string matching of group names, and identity providers complicate it. Microsoft Entra emits group object IDs (GUIDs) by default; human-readable formats exist but are conditional — sAMAccountName and the on-premises group SID only for groups synchronized from Active Directory through Microsoft Entra Connect, and display names only for cloud-only groups explicitly assigned to the application. Microsoft's own guidance is to prefer the object ID regardless, because it is immutable and unique, so plan on mapping GUIDs. Group claims are also capped at 150 for SAML assertions and 200 for JWT, nested groups included, and past the cap Entra does not truncate the list: the groups are omitted and "a link to the Microsoft Graph endpoint to obtain group information is included instead." For an app that reads roles straight from the token, that is a silent authorization failure — it sees no roles at all. Group filtering is the documented escape hatch, but it applies only when a user belongs to 1,000 or fewer groups, direct and transitive memberships combined (Microsoft Entra).

Implementing SCIM provisioning

A SCIM service provider exposes a small set of routes, secures them with a bearer token, and is configured into each IdP. The endpoint count is smaller than folklore suggests: a users-only implementation is six routes — the four below plus PUT and DELETE — and a full implementation is fifteen, six each for /Users and /Groups plus three read-only discovery routes. The skeleton below shows the core in TypeScript. Note that deprovisioning is an active: false update rather than a DELETE, sent as a PATCH by Entra and by integrations in the Okta Integration Network, and as a PUT by Okta integrations built with the App Integration Wizard.

import express, { type Request, type Response, type NextFunction } from 'express'
import { hash, timingSafeEqual } from 'node:crypto'

const sha = (s: string) => hash('sha256', s, 'buffer')

const scim = express.Router()
scim.use(express.json({ type: ['application/json', 'application/scim+json'] }))

// Every SCIM route is bearer-authenticated over TLS.
scim.use((req: Request, res: Response, next: NextFunction) => {
  const expected = process.env.SCIM_BEARER_TOKEN
  const match = req.header('authorization')?.match(/^Bearer (.+)$/)
  const token = match?.[1]
  // Fail closed: reject if the secret is unconfigured or the header is missing or
  // malformed. Hashing both sides first keeps the compare constant-time and equal
  // length, so timingSafeEqual never throws and no length is leaked.
  if (!expected || !token || !timingSafeEqual(sha(token), sha(expected))) {
    return res
      .status(401)
      .json({ schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'], status: '401' })
  }
  next()
})

// Provision a user — the IdP can call this before the user ever logs in.
scim.post('/Users', async (req: Request, res: Response) => {
  const user = await createUser(req.body) // map emails, userName, name, externalId
  res.status(201).json(toScimUser(user))
})

// Existence and idempotency checks: GET /Users?filter=userName eq "jane@acme.com"
scim.get('/Users', async (req: Request, res: Response) => {
  const results = await findUsers(req.query.filter as string | undefined)
  res.json(toListResponse(results))
})

scim.get('/Users/:id', async (req: Request<{ id: string }>, res: Response) => {
  const user = await getUser(req.params.id)
  return user ? res.json(toScimUser(user)) : res.sendStatus(404)
})

// Deactivation (active -> false) arrives in different shapes by IdP — Okta sends
// no path (value is the object { active: false }); Entra sends path "active"
// (boolean false only with the aadOptscim062020 compliance flag; the string
// "False" without it). RFC 7644 §3.10 makes attribute names case insensitive, so
// compare path that way. §3.10 says nothing about op values, but Entra capitalizes
// the op name, so lower-case that defensively too. This per-IdP variation is what
// makes PATCH the buggiest op.
function deactivates(ops: Array<{ op: string; path?: string; value: unknown }>): boolean {
  const isFalse = (v: unknown) =>
    v === false || (typeof v === 'string' && v.toLowerCase() === 'false')
  return ops.some((o) => {
    if (o.op?.toLowerCase() !== 'replace') return false
    if (o.path?.toLowerCase() === 'active') return isFalse(o.value) // Entra: path-based
    const v = o.value as Record<string, unknown> | null // Okta: no path
    return !o.path && typeof v === 'object' && v !== null && isFalse(v.active)
  })
}

// The lifecycle workhorse. A single PATCH can change attributes AND set
// active=false, so apply every operation first, then revoke sessions as a side
// effect of deactivation — never skip ops, and never DELETE.
scim.patch('/Users/:id', async (req: Request<{ id: string }>, res: Response) => {
  const ops = req.body.Operations as Array<{ op: string; path?: string; value: unknown }>

  await applyPatch(req.params.id, ops) // persists all changes, including active
  if (deactivates(ops)) {
    await revokeSessions(req.params.id) // active=false must also end live sessions
  }

  res.json(toScimUser(await getUser(req.params.id)))
})

export default scim

Two routes round out the set: PUT /Users/:id, which RFC 7644 requires and which App Integration Wizard integrations use for every update, and DELETE /Users/:id, rarely exercised because most IdPs deactivate rather than delete. The same verbs apply to /Groups, plus the three read-only discovery routes. The contrast with JIT is the headline: JIT adds zero new routes — it reuses the SSO login handler — while SCIM is a service you own. And PATCH is consistently the most bug-prone operation, because its exact shape differs by IdP — Okta and Microsoft Entra even encode the same active: false deactivation differently (Okta; Microsoft Entra).

SCIM gotchas to plan for

Most SCIM pain comes from per-IdP differences, even though everyone claims SCIM 2.0 compliance — a situation one IAM vendor calls "premature standardization" (Evolveum). The gotchas worth planning for:

  • Push timing varies, so the "SCIM experience" depends on the IdP. Okta is event-driven on the outbound side: it pushes changes to your app when data is modified in Okta, and issues SCIM requests whenever a user is assigned to the integration, rather than on a schedule. Okta publishes no latency figure for that push, and its scheduled hourly, daily, or weekly intervals apply only in the opposite direction — when Okta imports from an app that is the source of truth (Okta). Microsoft Entra, by contrast, provisions on a fixed background cycle that runs approximately every 40 minutes and is not configurable, though an admin can force a single user through sooner with on-demand provisioning (typically under 30 seconds) (Microsoft Entra: use-scim; known issues; provision on demand). Deprovisioning latency, therefore, is an IdP property, not something your app controls.
  • Not every directory is an equally capable SCIM source. Okta and Microsoft Entra push the full SCIM 2.0 lifecycle — users and groups — to any endpoint you expose. Google Workspace's outbound provisioning is narrower: it provisions users, and Google Groups serve only as a filter on which users get provisioned — the setting is labelled "Edit user groups subject to autoprovisioning" (Google Workspace). Vendors on the receiving end state the consequence outright: "SCIM automatic synchronization from Google Workspace only supports provisioning users. Automatic group provisioning is not supported at this time" (AWS). Two adjacent facts are easy to mistake for a change here: Google shipped inbound SCIM in July 2026, but that runs the other way — an external IdP provisioning users and groups into Google Workspace (Google Workspace Updates). And the limit is on what Google pushes, not on what a provider pulls: the Admin SDK Directory API exposes both users and groups to a read-only service account (Google Admin SDK), which is how platforms offering Google Workspace directory sync get group data anyway. Build your own endpoint and you inherit the users-only story; buy one and the question becomes whether the provider pushes or pulls.
  • Deprovisioning is deactivation, not deletion. Okta sends active: false and never issues a DELETE. Microsoft Entra defaults to the same disable — "for SCIM applications, a disable is a request to set the active property to false on a user" — and only sends a DELETE once the directory object itself is purged, because Entra hard-deletes users 30 days after they are soft-deleted; the window lives in Entra, not in your app. A target that does not support soft-delete gets the DELETE straight away. GitHub suspends enterprise users rather than deleting them (Okta; Microsoft Entra; GitHub). Build your endpoint around deactivation as the normal case.
  • Microsoft Entra's default SCIM payloads aren't RFC-compliant — a flag fixes it. By default Entra sends the active attribute as the JSON string "False" (not the boolean false), capitalizes the operation name (Replace), and structures some multi-attribute and group-removal PATCH operations in non-standard ways. Appending the aadOptscim062020 flag to the SCIM Tenant URL switches Entra to RFC 7644-compliant payloads. As of September 2026 it is still opt-in — Microsoft's page says the compliant behavior "is currently only available when using the flag, but will become the default behavior over the next few months," and attaches no date — so either set the flag or make your endpoint tolerant of both shapes (the skeleton above already is: it lower-cases op and accepts a string or boolean active). One interaction to plan around: the flag "currently doesn't work with on-demand provisioning," so the single-user escape hatch from the 40-minute cycle does not exercise the compliant payloads (Microsoft Entra).
  • Nested groups do not survive cleanly. Entra provisions only the direct members of an assigned group and "can't read or provision users in nested groups" (Microsoft Entra). Okta reaches a flat result by the opposite route: it "does not support nested groups" and instead "imports all nested directories for group members and adds the user to each group in Okta," so an Active Directory hierarchy lands as a flat set of individual memberships rather than a tree (Okta). Either way, expect flat membership on your side, or have admins assign the leaf groups directly.
  • Large-tenant initial sync is a load event. Onboarding a large customer generates a barrage of near-parallel group-membership PATCH requests — an IdP adding one user at a time to a 10,000-member group. If an ORM round-trips the whole group on each write, deleting and re-inserting every membership row, then "every request requires an exclusive lock on the join table," transactions serialize, connection pools max out, and most requests fail; scaling out more app server replicas makes it worse, because the extra database connections those replicas open only lengthen the queue waiting on that lock (DEV Community, a practitioner write-up rather than vendor documentation). The IdP-side consequence of that failure rate is documented: Microsoft Entra quarantines a provisioning job when "most, or all, of the calls made against the target system consistently fail" — concretely, when more than 40% of provisioning events fail — and a quarantined job drops to one incremental cycle per day, then is disabled outright if it stays there more than four weeks (Microsoft Entra). Fix the write path first — apply the membership delta instead of rebuilding the group — because once you are in that state, throttling "will just lead to timeouts at the gateway instead." A robust endpoint still returns HTTP 429 Too Many Requests with a Retry-After header so the IdP backs off — Okta honors this, pausing the task and doubling the wait on each retry. Read the fine print, though: Okta parses only integer seconds, so a missing header, a null value, or an HTTP-date all fall back to a five-minute default, and the backoff is bounded — "this retry process continues for a maximum of 10 attempts," after which "the task fails permanently and requires manual intervention" (Okta) — so rate-limiting too hard during a bulk assignment can strand users until an admin intervenes. Worth knowing: 429 is not defined by SCIM itself (RFC 7644 §3.12 does not list it; it comes from RFC 6585), so treat it as standard HTTP back-pressure layered on top of SCIM.
  • A cloud IdP can only push to an endpoint it can reach. SCIM is server-to-server: the IdP sends provisioning requests into your SCIM endpoint, so that endpoint must be reachable from the IdP. For a public SaaS that is automatic, but an internal tool with no public ingress cannot be reached by a cloud IdP directly — the team running the IdP has to deploy an outbound provisioning agent inside the network (such as the Okta Provisioning Agent or Microsoft Entra's on-premises provisioning agent), which connects outbound so no inbound firewall ports are opened. JIT sidesteps this entirely, because the SAML assertion or OIDC token travels through the user's browser at sign-in rather than over a direct IdP-to-app connection.
  • Ordering and retries. Group membership depends on user creation: the spec warns that "before a User can be added to a Group, they must first be created. Processing these requests out of order might result in a failure to add the new User to the Group" (RFC 7644 §3.7). Entra sequences around this, syncing users and groups first and memberships afterward, and it retries what fails without being asked: "the operation is retried in the next sync cycle," and "the errors are continually retried, gradually scaling back the frequency of retries" (Microsoft Entra). Okta re-queues failures on its own, through the 10-attempt doubling back-off described above (Okta). Every failure is therefore a repeat delivery waiting to happen, so make every write safe to repeat: reject duplicate creates with 409 Conflict on userName as the spec requires, and key on externalId — the provisioning client's own identifier, whose uniqueness the spec leaves to the client rather than enforcing on the server (RFC 7643).

How JIT and SCIM coexist (and when to turn JIT off)

When SCIM owns the lifecycle, leaving JIT on can create conflicting or duplicate records, or let a login overwrite attributes that SCIM is supposed to manage. That is why teams often disable JIT once SCIM is authoritative — but which side wins is a platform configuration choice, not a protocol rule. Docker defaults to JIT overwriting SCIM (Docker), whereas data.world goes the other way: "When you enable SCIM, JIT provisioning is automatically disabled and SCIM becomes the only method of user provisioning" (data.world). The dedup contract that keeps the two from colliding is a stable shared identifier: externalId on the SCIM side matched to the NameID or sub on the login side.

Clerk states the consequence in stronger terms than "duplicate records": "To ensure users are provisioned only through Directory Sync, disable JIT Provisioning for the SAML connection. If both are enabled, users who haven't been synced from your IdP to Clerk won't be able to sign in to your application." That is a lockout, not a data-consistency nuisance. Turning Directory Sync on also transfers ownership of the synced fields: the directory "becomes the exclusive source for those attribute values and they're read-only in Clerk until Directory Sync is disabled" — not editable from the Dashboard or by the user in <UserProfile /> (Clerk; Clerk changelog).

Build vs. buy

Building SCIM in-house looks like a handful of endpoints, but the cost is in the long tail: per-IdP quirks (with PATCH the buggiest operation), idempotency, large-tenant load, and continuous maintenance as IdPs change (WorkOS). A practitioner estimate from Hashorn puts a self-built implementation at "4 to 8 weeks of engineering plus ongoing operational complexity around edge cases," stated for SCIM generally rather than for any particular IdP count. Vendor modeling runs higher and is explicitly additive per IdP: WorkOS's build-versus-buy analysis budgets about 600 engineer-hours — just under four months for a team of three — for base SCIM infrastructure alone, plus roughly 80 hours for each additional IdP. Both are interested estimates rather than neutral benchmarks, but the direction is consistent.

Observability is an underrated part of this decision. When an in-house endpoint silently fails to provision a user, your team often cannot see why, because the IdP's own provisioning telemetry lives on the IdP side — Microsoft Entra's provisioning logs, and on Okta both the admin Tasks queue, where provisioning errors surface for retry, and the System Log, which records the underlying application.provision.* events — while, as WorkOS puts it, app developers "don't have access to these logs" (WorkOS, a vendor source). Triage then degrades into a back-and-forth with the customer's IT admin. A managed provider supplies the missing application-side view — for example, Clerk's "Directory users" tab shows each provisioned user's status and last sync time (Clerk). For many teams, that operational support — not just the endpoints — is the real argument for letting an identity provider handle provisioning.

How authentication providers handle provisioning

Most teams do not build provisioning from scratch; they get it from their authentication or identity platform.

The vendor landscape

First, a direction check that prevents a lot of confusion: the workforce identity providers your customers operate (Okta, Microsoft Entra ID, OneLogin) push provisioning outward, while the authentication platforms you embed in your app receive it. When you shop for "a SCIM solution," you are shopping for the receiving side.

On that receiving side, mature B2B and CIAM platforms commonly support both JIT and SCIM, with quality and coverage varying by product. WorkOS, Auth0, Frontegg, Stytch, SSOJet, Scalekit, PropelAuth, and Descope all document SCIM-based directory sync alongside SSO, and several add self-service admin portals so your customers can configure their own connections. Across the industry, SCIM and directory sync tend to sit on higher or enterprise tiers rather than free entry plans: an analysis of 721 SaaS applications found 42% lock SCIM behind enterprise pricing and only nine include it on the base tier (Stitchflow, an interested party — it sells a workaround). Specific tiers and prices change often; verify against each vendor's current pricing. The point of this section is capability, not a price comparison: the meaningful differences between providers are SCIM scope (users only versus users and groups), deprovisioning immediacy, group-to-role mapping, and whether a hosted admin portal is included.

Provisioning with Clerk

Clerk supports both approaches, which maps cleanly onto the decision framework in Part 1: JIT for first-login onboarding and Directory Sync (SCIM) for the full lifecycle, plus a lighter OIDC-based middle option. Pricing and feature details below are current as of September 2026 and are point-in-time — verify them against Clerk's pricing page before you commit.

Clerk optionHow users are addedWhat happens when users are removedIdP coverage
JIT provisioningClerk creates a user during the user's first SAML SSO sign-in.JIT does not deprovision users, because no login event happens when the IdP removes them. Use Directory Sync when users must be provisioned and deprovisioned automatically.All supported SAML providers: Microsoft Entra ID, Google Workspace, Okta Workforce, and custom SAML providers.
Directory Sync (SCIM)For Okta, Microsoft Entra ID, and custom providers, the IdP pushes SCIM 2.0 create, update, and deactivate events to Clerk. For Google Workspace, Clerk pulls directory data through the Google Admin SDK at 5-minute granularity.Clerk deactivates the corresponding user and immediately revokes their active sessions. The upstream sync that delivers the change can take several minutes.Okta, Microsoft Entra ID, Google Workspace, and any custom SCIM 2.0 provider. Requires an existing SAML or OIDC enterprise connection.
EASIEUsers sign in through Clerk's OIDC-based enterprise SSO option, and accounts are created on first sign-in.Before issuing a new session token, Clerk checks for upstream deprovisioning — suspended or deleted in Google Workspace, deleted in Microsoft Entra. Detection can take up to 10 minutes; on detection Clerk revokes existing sessions and returns 401 Unauthorized for new session-token requests.Google Workspace and Microsoft Entra ID.

JIT provisioning during SAML SSO

JIT is on by default for every SAML connection: Clerk creates an account on a user's first SAML SSO sign-in, reading identity from the assertion, and keeps that data current on later sign-ins. Attribute sync is on by default too — the Sync user attributes toggle is one you switch off, not on. To disable JIT itself, open the connection on the SSO connections page (sign-in required; the Dashboard link returns an error if you are signed out), select the Settings tab, and toggle off Create users during sign-in; the same setting is exposed as the disable_jit_provisioning property on the Backend API's Update Enterprise Connection endpoint. Once it is off, a user with no Clerk account gets the saml_jit_provisioning_disabled error instead of an account. This is documented for Clerk's SAML connections — Microsoft Entra ID, Google Workspace, Okta Workforce, and custom SAML — and is the SAML-side counterpart to the SCIM lifecycle (Clerk).

Directory Sync (SCIM) for the full lifecycle

Clerk's Directory Sync (SCIM) is generally available (Clerk changelog). It carries no separate fee, but it runs on top of an enterprise connection, so a production instance needs the Pro or Business plan and is billed at enterprise-connection rates. It provides automated provisioning, deprovisioning with immediate session revocation, attribute syncing, and group syncing. When a user is removed or deactivated in the IdP, Clerk deactivates the corresponding Clerk user and immediately revokes all of their active sessions — so deprovisioning is enforced at once, not on a delay (Clerk). Custom attribute mapping and group-to-role mapping are also generally available (the rollout completed in May 2026), with custom attributes mapped into the user's publicMetadata and role mapping enabled by default (Clerk changelog). Because Clerk's implementation follows the SCIM 2.0 protocol, it works with any IdP that speaks SCIM 2.0, with step-by-step setup guides published for Okta and Microsoft Entra ID; Google Workspace is the exception, read through the Google Admin SDK instead. One practical consequence of running on someone else's endpoint: the per-IdP PATCH divergence from earlier in this article — Okta's no-path deactivation shape, Entra's "False" string, the aadOptscim062020 flag — lands on Clerk's side of the connection rather than on code you maintain. That is true of any managed provisioning endpoint, not a Clerk-specific advantage; it is just the part of the build-versus-buy calculation that is easiest to underestimate before you have written the PATCH handler yourself.

A third tier: EASIE

Between JIT and full SCIM sits EASIE, Clerk's OIDC-based enterprise SSO option for Google Workspace and Microsoft Entra ID. Its distinguishing feature is the deprovisioning check in the table above: reactive rather than pushed, capped at roughly 10 minutes, and scoped to what each provider exposes — suspension or deletion for Google Workspace, deletion only for Microsoft Entra (Clerk). It is a real middle ground — lighter than SCIM, but with real deprovisioning that JIT lacks — and it is not a standard provisioning protocol, so it does not replace SCIM where the full lifecycle or broader IdP support is needed.

Example configuration

Setup stays in the Dashboard. Open the SSO connections page (sign-in required; the Dashboard link returns an error if you are signed out), select your enterprise connection, choose the Directory sync tab, and select Set up directory sync. For Okta, Microsoft Entra ID, and custom providers, Clerk generates a SCIM endpoint URL and a Bearer token under Connection details; paste both into your IdP's provisioning configuration. Google Workspace is the exception — Clerk asks for a service account key and a delegated admin email instead. Because Directory Sync requires an existing SAML or OIDC enterprise connection, there is no standalone SCIM-only setup.

Fair caveats and requirements

A few honest constraints. SCIM provisioning and deprovisioning are included with the enterprise connection (there is no separate Directory Sync line item on Clerk's pricing page), but enterprise connections themselves are a paid, metered feature: one is included on the Pro plan, with additional connections metered on a sliding scale (Clerk pricing). Group-to-role mapping has exactly one hard prerequisite: the connection must be linked to an organization, because the mapping draws its roles from that organization's Role Set. Custom roles are not required — the free Primary Role Set already ships org:admin and org:member, and IdP groups can map straight to those. The paid boundary is the link itself: linking enterprise connections with organizations is a B2B Authentication add-on feature, as are custom roles and additional Role Sets. Finally, a concrete requirement that catches teams off guard: Clerk requires an email in the SCIM emails attribute for every provisioned user and will not fall back to userName, so a SCIM payload that omits an email fails to provision that user (Clerk). Clerk does not document a specific merge or dedup behavior for pre-existing JIT users when SCIM is later enabled on the same connection, so do not assume automatic reconciliation. What Clerk does document is the sign-in path, and it cuts both ways: when the IdP returns a matching verified email, Clerk links the enterprise SSO account to the existing account, but when the email comes back unverified, Clerk "doesn't link the Enterprise SSO account to the existing account, but instead signs the user up and creates a completely new account" (Clerk). That is login-time linking, not a bulk backfill of users SCIM has never seen, so treat a JIT-to-SCIM switchover as a migration to plan rather than one to assume.

Conclusion: choosing the right provisioning approach

The decision rule is short: use JIT for low-friction first-login onboarding, and use SCIM when you need attribute updates, day-one pre-provisioning, or automated deprovisioning. Because they cover different stages of the same lifecycle, most teams end up running both — shipping JIT first for speed, then adding SCIM as the lifecycle, compliance, or enterprise need arrives. The deprovisioning gap is the factor that most often forces the move to SCIM, but the right weighting of deprovisioning, compliance, customer size, engineering cost, and timing is yours to make for your own product.

Whichever way you lean, you do not have to build it alone. Authentication providers — Clerk among them — support both JIT and SCIM, so you can start with first-login provisioning and turn on the full directory-synced lifecycle when your customers ask for it, without re-architecting your auth. That closes the two-part series: Part 1 for the decision framework — when JIT is enough, and when the deprovisioning gap forces SCIM — and Part 2 for the implementation reality of running both in production.

In this series

  1. SCIM vs JIT provisioning: when to use each
  2. SCIM vs JIT provisioning: when to use each - Part 2 (you are here)