ServicesSCIM Provisioning

SCIM Provisioning

The surface an identity provider drives to keep an organization's membership in step with a corporate directory: Okta or Entra creates, renames, and deprovisions people over SCIM 2.0 (RFC 7643 and RFC 7644). It lives at /scim/v2, outside the versioned API, because a provisioning client is configured with a base URL and appends the RFC's own paths to it.

When to reach for it: an enterprise customer wants joiners and leavers in their directory to reach SnugNut without anyone clicking invite, or you are certifying an integration and need to know which attributes this server actually applies.

When not to: interactive signup, sessions, invitations, API keys, and the identity provider configuration itself belong to Auth. SCIM only moves people in and out of an organization whose provider is already configured and enabled.

SCIM speaks RFC 7644, not the platform envelope

Requests and responses under /scim/v2 are bare SCIM documents, and failures are SCIM error bodies rather than the platform envelope, which names SCIM as one of its two exceptions. status is a string - a directory rejects the response outright when it is numeric - and scimType appears only where the RFC defines one:

{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
  "status": "400",
  "scimType": "invalidFilter",
  "detail": "this server does not support filtering; see ServiceProviderConfig"
}

Branch on the HTTP status and scimType; detail is for a human. The capitalized paths (/Users, /ServiceProviderConfig) are the RFC's own spelling, not a local convention.

Concepts

  • The credential names the organization. It is an API key carrying an org and an identity provider, so no verb takes an org argument: a key can only ever see the directory it was issued for.
  • scim:write is asked for by name. A * wildcard grants every other service but not SCIM, because provisioning creates and removes accounts.
  • userName is the work address and its domain must be one the provider has proven. Both it and externalId, the directory's own id for the person, are set at provisioning and never re-pointed.
  • Only active and displayName are writable. The rest of the User schema is read-only or immutable, and a push of anything else is refused rather than silently dropped.
  • Deprovisioning withdraws a membership, not an account. The person keeps their account and their other organizations.

Discovery

The three discovery documents are served without a credential, because a directory reads them before an administrator has given it one:

snug scim discovery service-provider-config
snug scim discovery resource-types
snug scim discovery schemas

ServiceProviderConfig is honest about what is built: patch is the only capability it advertises, while filter, bulk, sort, etag, and changePassword all report false.

The provisioning credential

An organization administrator mints it against an enabled identity provider, and both identifiers are required alongside a SCIM scope:

snug auth api-key create --name okta-provisioning --scopes scim:write \
  --org-id "$ORG_ID" --provider-id "$PROVIDER_ID"

That plaintext key is what the directory sends as its bearer token, and what SNUG_API_TOKEN holds for the snug scim commands below. Registering the provider and proving its domain is Auth's job, not SCIM's - see the Auth guide. A missing or unusable credential is 401 with WWW-Authenticate: Bearer realm="SCIM", a user session token included: provisioning is keyed to a key, never to a person.

Provisioning users

snug scim users list
snug scim users list --start-index 2 --count 25
snug scim users get DXjNpKKPNLSVqrAmcqpT
snug scim users create --user-name ada@acme.example --display-name "Ada Lovelace" \
  --external-id okta-00u1

Over HTTP, provisioning is a POST of a SCIM user document, and the answer is one too, with no envelope around it:

curl -X POST -H "Authorization: Bearer $SCIM_KEY" \
  -H "Content-Type: application/scim+json" \
  -d '{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"ada@acme.example","externalId":"okta-00u1","displayName":"Ada Lovelace"}' \
  http://localhost:4000/scim/v2/Users
{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
  "id": "DXjNpKKPNLSVqrAmcqpT",
  "userName": "ada@acme.example",
  "active": true,
  "name": { "givenName": "Ada", "familyName": "Lovelace", "formatted": "Ada Lovelace" },
  "displayName": "Ada Lovelace",
  "emails": [{ "value": "ada@acme.example", "primary": true, "type": "work" }],
  "externalId": "okta-00u1",
  "meta": { "resourceType": "User", "created": "2026-08-28T22:57:40.453308+00:00",
            "location": "http://localhost:4000/scim/v2/Users/DXjNpKKPNLSVqrAmcqpT" }
}

name is split out of displayName rather than stored, which is why the schema declares it read-only. Listing is 1-based: startIndex and count come back as startIndex, itemsPerPage, and totalResults.

Profile pushes and departures

PATCH and PUT reach the same door, so the two verbs cannot disagree about a push. Both apply displayName and accept externalId only as a replay of the value already linked, and both match attribute names case-insensitively as RFC 7643 section 2.1 requires, so DisplayName lands like displayName.

snug scim users replace DXjNpKKPNLSVqrAmcqpT --user-name ada@acme.example \
  --display-name "A. Lovelace"
snug scim users patch DXjNpKKPNLSVqrAmcqpT \
  --body '{"Operations":[{"op":"replace","value":{"displayName":"A. Lovelace"}}]}'
snug scim users deactivate DXjNpKKPNLSVqrAmcqpT   # PATCH active:false
snug scim users delete DXjNpKKPNLSVqrAmcqpT --force

A directory expresses a departure as active: false over PATCH or PUT, or as a DELETE; all three land on the same withdrawal. The first two answer 200 with the user rendered active: false, DELETE answers 204, and afterwards the user is gone from the list and a direct read is 404 because the membership that resolved them no longer exists. The account survives, which is why there is no reactivate verb: provision the person again with the same userName and the original id comes back.

Behaviors and gotchas

  • A wildcard scope grants nothing here. A key with * is 403 the credential does not carry the scim scope for this operation, as is a scim:read key attempting any write verb.
  • A disabled provider stops provisioning with 403 the identity provider this credential belongs to is disabled, and a key cannot be minted against a disabled provider in the first place.
  • userName must sit in a proven domain, or creation is 400 naming the address's domain as not a verified domain for this provider. A duplicate userName is 409 with scimType: "uniqueness".
  • A request this server will not apply is named, not dropped. Patching title is 400 this server does not apply title over PATCH, changing userName is 400 with scimType: "mutability", and creating someone already deactivated is 400 this server does not create deactivated users.
  • A create on an address that already has an account adopts it and drops the displayName push. True both for a rehire, meaning an account this provider deprovisioned earlier, and for someone who signed up directly: both return 201 carrying the original id and keep the stored name, so a first sync reports a name it never set. Push it afterwards with PUT or PATCH. If the person is still a member, the create is 409 instead.
  • An unverified self-signup blocks provisioning with 409 an unverified account already holds {address}; it must be verified or removed before it can be provisioned - the common day-one rollout failure. It takes all three: no existing link, an unverified address, and an account the person set up themselves with a password or passkey. A verified self-signup is adopted, and so is an account this provider created.
  • The organization owner cannot be deprovisioned: 400 this user owns the organization; transfer ownership before deprovisioning.
  • The CLI reshapes lists. snug --output json scim users list prints its own flattened view (total_results, resources), where the discovery commands print their document verbatim. For byte-exact SCIM, call the endpoint directly.

Provisioning runs unattended, so failures are kept where an administrator can find them. The drift report lists members who joined without a SCIM link, links pointing at people who are no longer members, and recent refusals, including credential failures that never reached a handler:

snug auth idp drift --org "$ORG_ID" --provider "$PROVIDER_ID"

Limits and configuration

Authenticated traffic is metered per credential and carries the usual ratelimit-limit, ratelimit-remaining, and ratelimit-reset headers; over the allowance the answer is a SCIM error with "status": "429", so a client backs off instead of reading a throttle as a transport failure. The provider, domain, and API key knobs behind a provisioning credential live with Auth, in the AUTH_SERVICE_* variables of the Auth CONFIG reference.

Reference

  • SCIM API - every endpoint, callable
  • Related: Auth for organizations, identity providers, domain proof, and API keys

On this page