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

Welcome to Part 2 of our guide on SCIM and JIT provisioning. In Part 1, we covered the core concepts, the deprovisioning gap, and a decision framework for choosing between the two approaches. In this second part, we dive into the technical reality of implementing JIT and SCIM, common gotchas — especially around per-IdP variations — the build vs. buy decision, and how modern authentication providers handle provisioning in practice.

Implementation considerations and common gotchas

The conceptual difference is clean. The implementations are where the surprises live. This section covers what each side actually requires and the production gotchas worth planning for.

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 sends group object IDs (GUIDs), not human-readable names, and group claims in a token are capped at 150 for SAML assertions and 200 for JWT, with anything beyond the cap causing the group claims to be omitted entirely (Microsoft Entra). That last detail is a silent failure: past the limit, roles do not just truncate, they disappear.

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 minimal users-only implementation is around five routes, and a full users-plus-groups-plus-discovery implementation is closer to fifteen, not the "sixteen endpoints" sometimes quoted. The skeleton below shows the core in TypeScript; note that the deprovisioning path is the active: false branch of PATCH, not DELETE.

import express, { type Request, type Response, type NextFunction } from 'express'

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/malformed.
  if (!expected || !token || token !== 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, 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). op casing varies too (Entra: "Replace"), so compare
// case-insensitively. 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 === '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, 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 but are used less often: PUT /Users/:id (full replace) 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 pushes changes in an event-driven way — it is notified when a user is created, assigned, changed, or deprovisioned, and acts on it near-immediately (not on a poll) (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, but Google Workspace's automated provisioning is narrower: it provisions users, not groups. Google Groups can only scope which users are provisioned; the groups themselves are never created or synced in the target app, so there is no automatic group provisioning (Google Workspace; Keeper). If your customers standardize on Google Workspace, plan for a users-only directory-sync story rather than the group sync Okta or Entra provide.
  • Deprovisioning is deactivation, not deletion. Okta sends active: false and never issues a DELETE; Entra disables first and only hard-deletes after a soft-delete window; GitHub suspends enterprise users rather than deleting them (Okta; 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 early 2026 it is still opt-in — Microsoft has long said the compliant behavior "will become the default," but it hasn't — 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) (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"; Okta flattens nested Active Directory groups into the parent on import (Microsoft Entra; Okta). Expect flattened membership, or have admins assign the leaf groups directly.
  • Large-tenant initial sync is a load event. Onboarding a big customer can fire thousands of near-concurrent POST requests, which can exhaust connection pools and trigger N+1 database patterns (DEV Community). A robust endpoint returns HTTP 429 Too Many Requests with a Retry-After header so the IdP backs off — Okta honors this, pausing the task and using exponential backoff, and reads only integer-seconds Retry-After values (Okta). 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 idempotency. A group can reference a user before that user's POST has landed, producing a transient 404; use externalId as the stable key to make retries idempotent (Stytch).

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 automatically disables JIT when SCIM is enabled (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.

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. Effort estimates range from roughly 4–8 weeks for a single-IdP MVP (a practitioner estimate from Hashorn) up to several months for a hardened, multi-IdP implementation (per WorkOS's vendor ROI modeling). Both are interested estimates, not 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 logs live on the IdP side — Microsoft Entra's "provisioning logs" and Okta's "System Log" — while, as WorkOS puts it, app developers "don't have access to these logs" (WorkOS, a vendor source; Microsoft Entra provisioning logs). 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. Here is how that landscape looks, and where Clerk fits.

The vendor landscape (a fair, balanced survey)

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 offer 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 — a durable pattern worth budgeting for, though specific tiers and prices change often and should be verified 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 framework above: 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 June 2026 and are point-in-time — verify them against Clerk's pricing page before you commit.

JIT provisioning during SAML SSO

Clerk creates an account on a user's first SAML SSO sign-in, reading the identity from the assertion, and can keep user data current on subsequent sign-ins via a "Sync user attributes during Sign in" toggle. This is documented for Clerk's SAML connections — Microsoft Entra ID (Azure AD), 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 and enabled for all users. 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). Documented SCIM identity providers are Okta and Microsoft Entra ID, and Directory Sync is configured per enterprise connection.

A third tier: EASIE

Between JIT and full SCIM, Clerk offers EASIE — an OIDC-based enterprise SSO option for Google Workspace and Microsoft Entra ID. Before issuing a new session token for an EASIE user, Clerk re-checks whether that user has been deprovisioned in the OpenID provider (suspended or deleted upstream); detecting that change can take up to roughly 10 minutes, after which Clerk revokes the user's existing sessions and returns a 401 to any new token request (Clerk). It is a 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 is concept-first and stays in the dashboard: you enable Directory Sync on an existing enterprise connection, Clerk generates an Endpoint URL (the SCIM base URL) and a bearer token, and you paste both into your IdP's provisioning configuration. Because Directory Sync requires an existing SAML or OIDC enterprise connection, there is no standalone SCIM-only setup — it builds on a connection you already have.

Fair caveats and requirements

A few honest constraints. Directory Sync requires an existing enterprise connection and is not standalone, so it complements your choice rather than removing it. 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 additionally requires linking the connection to an organization and using custom roles — capabilities packaged in Clerk's B2B Authentication add-on — so the mapping feature is GA and enabled by default, but the org-link-plus-custom-roles prerequisite is a paid boundary (pricing is point-in-time and packaging can change — verify at Clerk's pricing page). 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; on the sign-in path, Clerk does auto-link accounts by verified email (Clerk).

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.

This concludes our two-part series on SCIM and JIT provisioning — Part 1 covered the decision framework (when JIT is enough, and when the deprovisioning gap forces SCIM), and Part 2 covered the implementation reality of running both in production.

Frequently asked questions (FAQ)

What is the most common issue when implementing SCIM?

The biggest challenge is handling per-IdP variations. While SCIM 2.0 is a standard, providers like Okta and Microsoft Entra format payloads differently — especially for PATCH operations and deactivation — meaning your endpoint must be tolerant of multiple data shapes.

Should I build a SCIM endpoint in-house or buy a solution?

Building a basic SCIM endpoint is relatively straightforward, but maintaining it across multiple IdPs, handling large-tenant initial sync loads, and ensuring idempotency can take months of engineering effort. Many teams opt to use an authentication provider that offers SCIM out of the box to avoid this ongoing maintenance burden.

How do JIT and SCIM coexist in the same application?

They can coexist by using a stable shared identifier (like externalId mapped to the SAML NameID). However, to prevent conflicting updates, many platforms disable JIT attribute syncing once SCIM becomes the authoritative source for the user lifecycle.

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)