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:writeis asked for by name. A*wildcard grants every other service but not SCIM, because provisioning creates and removes accounts.userNameis the work address and its domain must be one the provider has proven. Both it andexternalId, the directory's own id for the person, are set at provisioning and never re-pointed.- Only
activeanddisplayNameare 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 schemasServiceProviderConfig 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-00u1Over 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 --forceA 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
*is403 the credential does not carry the scim scope for this operation, as is ascim:readkey 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. userNamemust sit in a proven domain, or creation is400naming the address's domain asnot a verified domain for this provider. A duplicateuserNameis409withscimType: "uniqueness".- A request this server will not apply is named, not dropped. Patching
titleis400 this server does not apply title over PATCH, changinguserNameis400withscimType: "mutability", and creating someone already deactivated is400 this server does not create deactivated users. - A create on an address that already has an account adopts it and drops
the
displayNamepush. True both for a rehire, meaning an account this provider deprovisioned earlier, and for someone who signed up directly: both return201carrying the originalidand keep the stored name, so a first sync reports a name it never set. Push it afterwards withPUTorPATCH. If the person is still a member, the create is409instead. - 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 listprints 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.