SCIM 2.0 provisioning
Users, Groups and real deprovisioning
Edit this pageOn this page
SCIM is what OIDC cannot do: an employee who leaves loses access without anyone remembering to remove them.
OIDC only tells you about people at the moment they authenticate. A user deactivated in the directory simply stops signing in — their existing session keeps working, their membership stays, and nothing anywhere records that they should be gone. SCIM closes that.
Base URL and authentication
https://loomscope.example.com/api/scim/v2Authenticate with a bearer token minted under Settings → SCIM. It is an
api_keys row with a scim:manage scope — the same table and the same
SHA-256 hashing as a daemon key, so this product has one credential story
rather than two. It is shown once.
Authorization: Bearer lsk_scim_...A daemon key cannot provision members and a SCIM token cannot post observations. The scope is what separates them.
Supported resources
| Endpoint | Methods |
|---|---|
/ServiceProviderConfig | GET |
/ResourceTypes | GET |
/Schemas | GET |
/Users | GET, POST |
/Users/{id} | GET, PATCH, DELETE |
/Groups | GET, POST |
/Groups/{id} | GET, PATCH, DELETE |
GET /Users supports filter=userName eq "..." and GET /Groups supports
filter=displayName eq "..." — the two filters every directory issues before
creating a resource.
An unsupported filter is refused, not ignored. Answering a filter this server does not understand with the whole list would let a provider conclude that a user it has never seen already exists, and then never create them.
Users
A SCIM user is a membership, not a person. The id is the membership id, so the same person in two organisations is two SCIM users and a directory provisioning one cannot address the other.
Provisioned members start at the weakest role. SCIM says who exists, not what they may do; roles come from group mapping or from the OIDC claim mapping.
Deprovisioning
Three shapes arrive, and all three mean the same thing:
// Okta
{ "Operations": [{ "op": "replace", "path": "active", "value": false }] }
// Entra ID
{ "Operations": [{ "op": "replace", "value": { "active": false } }] }
// Okta, for removal
DELETE /Users/{id}Handling one and not the others ignores a share of the deprovisioning events
that arrive, and nothing reports a failure — from the directory's side it
succeeded. Both PATCH shapes are handled, including the string "False" that
turns up when a value came out of a template.
Deprovisioning marks rather than deletes. The membership survives as a record of who had access and when it was removed — and it ends the sessions they already hold. Without that, deactivating somebody stops their next sign-in and leaves the current one running.
A deprovisioned membership is also refused at the OIDC callback, so a valid directory login does not resurrect it.
Groups
Groups arrive with no role, and an administrator says what one is worth.
A directory that pushes its whole group tree must not be able to promote anybody by doing so — and inferring a role from a group's name would be a security decision made by a string comparison.
What a member's role becomes
The strongest role of their groups. The same rule the OIDC claim mapping uses, for the same reason: the order a directory lists groups in is not something an operator can see.
Two deliberate refusals:
- Leaving a group does not demote. When somebody's remaining groups grant nothing, their role is left as it is rather than reset to the weakest. Otherwise a directory reorganising its groups silently strips a role an operator set by hand, and the person finds out by losing access.
- Deleting a group changes nobody's role, for the same reason. One delete should not be able to do that much damage.
Membership operations
All four shapes a directory actually sends:
// Add a list
{ "Operations": [{ "op": "add", "path": "members",
"value": [{ "value": "membership-id" }] }] }
// Remove a list
{ "Operations": [{ "op": "remove", "path": "members",
"value": [{ "value": "membership-id" }] }] }
// The targeted remove — one person out of a group of five thousand
{ "Operations": [{ "op": "remove",
"path": "members[value eq \"membership-id\"]" }] }
// Replace the whole membership. Anyone absent has left.
{ "Operations": [{ "op": "replace", "path": "members",
"value": [{ "value": "membership-id" }] }] }A replace re-evaluates the people who were in the group as well as the ones named, because those are the people whose role may need to change and they are not in the incoming list to be noticed otherwise.
Setting it up
Okta
- In your Okta application, enable provisioning with SCIM 2.0.
- Base URL:
https://loomscope.example.com/api/scim/v2 - Unique identifier field:
userName - Supported actions: push new users, push profile updates, push groups, deactivate users.
- Authentication mode: HTTP Header, with the bearer token.
Entra ID
- Enterprise application → Provisioning → Automatic.
- Tenant URL:
https://loomscope.example.com/api/scim/v2 - Secret token: the bearer token.
- Test Connection before saving — it exercises
/ServiceProviderConfig, which is why that endpoint exists.
Verifying it works
The things worth checking, in order of how quietly they fail:
| Check | How |
|---|---|
| The directory can reach the server | Its own Test Connection |
| Creating a user works | Provision one; it appears under Settings → People |
| Deactivation ends the session | Sign that user in, deactivate them, confirm they are signed out |
| Group role mapping applies | Set a role on a group; add a member; check their role changed |
The third one is the point of the whole integration, and it is the one that looks fine when it is broken.
Limitations
- No
PUTon resources.PATCHis what directories send. - No pagination beyond a 200-row cap on list responses. Filters are the intended access path.
- Roles are not writable over SCIM directly — they come from group mapping, so that the directory cannot escalate a role by writing an attribute.