Product documentation
Provision users and groups with SCIM 2.0
Auto-provision and deprovision users from a tenant's IdP over SCIM 2.0 — the identity-service /scim/v2/Users and /Groups endpoints, the bearer-token contract, attribute mapping, and the supported filter and PATCH surface.
Provision users and groups with SCIM 2.0
SCIM 2.0 (RFC 7643 / RFC 7644) lets a tenant's identity provider — Entra ID, Okta, or any SCIM-capable IdP — push its user directory into Thoryn and keep it in sync. A new hire in your IdP becomes a Thoryn user without anyone clicking anything; a leaver is deactivated the moment your IdP says so.
SCIM is served by identity-service, Thoryn's own OIDC provider and federation member. It is the store that owns user rows, so it is the store SCIM writes to. The authorization hub holds no user data and has no SCIM surface.
SCIM provisioning and federated sign-in are different things. SCIM creates the account ahead of time; federation authenticates the person at sign-in. Most enterprise deployments use both — see Add a federation member for the sign-in half. SCIM is what makes a user (and their deactivation) exist in Thoryn without a login having happened yet.
For end users
A tenant administrator configures one SCIM connection in their IdP:
- Point the IdP's SCIM connector at the base URL —
https://<identity-host>/scim/v2./Usersand/Groupsare co-located under that one base URL, which is what Okta and Entra expect. - Give it a bearer token — a hub-issued OAuth token carrying the
scimscope and atenant_idclaim (see The bearer token). - Map attributes — the IdP's default SCIM mapping works out of the box; per-connection overrides are available (see Attribute mapping).
- Assign users to the application in the IdP. The IdP then pushes creates, updates, and deactivations on its own schedule.
Your IdP discovers what this server supports by reading
GET /scim/v2/ServiceProviderConfig — filtering and PATCH are supported; bulk operations,
sorting, and ETags are not. Sorting is advertised as unsupported because it is: sortBy and
sortOrder are not honoured on either list endpoint, so results come back in the store's own
order. A well-behaved IdP reads that flag and stops sending the parameters.
The bearer token
Every /scim/v2/** resource request authenticates with
Authorization: Bearer <token> (RFC 6750 — the scheme the ServiceProviderConfig advertises).
The token is validated against the authorization hub's JWKS (ES256, with RS256 accepted
defensively), and it must satisfy two conditions:
| Requirement | Why |
|---|---|
Scope scim | Authorizes the /scim/v2/** surface. Any other token is 403. |
A tenant_id claim | Scopes every row the connection reads or writes. Missing it fails the request — it is not defaulted. |
An optional third claim, federation_member_id, identifies which IdP owns the connection.
When present, provisioned users are stamped with that federation provider and
attribute mapping rules for that connection are applied. When absent,
both behaviours are skipped and provisioning still works.
The SCIM token is a client-credentials token from a registered OAuth client that holds the
scim scope. The tenant_id claim is minted onto its access token by a static
claims-mapping rule on that client:
[
{ "claim": "tenant_id", "value": "acme", "source": "static", "targets": ["access_token"] }
]Registering a SCIM client and setting its claims_mapping is an administrative operation on
the hub's client-management API today — it is not part of the tenant console's OAuth-client
workflow. Ask your Thoryn contact (managed SaaS) or run it against the hub's admin client API
(self-managed) when setting up a SCIM connection.
The token is a long-lived credential held by your IdP. Treat it like any other provisioning secret — see Security notes.
User provisioning
/scim/v2/Users is the lifecycle surface. All requests and responses use the
application/scim+json media type (application/json is also accepted on request bodies).
Attribute mapping to the Thoryn user
| SCIM attribute | Thoryn user field | Notes |
|---|---|---|
userName | email | The canonical identifier. Must be unique per tenant. |
emails | email | Takes precedence over userName when present: the entry with primary: true wins, else the first entry with a non-empty value. Entra's default mapping sends both identically. |
externalId | external_id | Your IdP's stable id (Entra sends the directory oid). Blank and the literal string "null" both normalize to "no externalId". |
name.givenName | given_name | |
name.familyName | family_name | |
locale | locale | |
active | status | true → ACTIVE, false → SUSPENDED. Honoured on create as well as update. |
password | password | Optional on create. Most federated deployments omit it — the user authenticates at their IdP. |
The enterprise-user extension (urn:ietf:params:scim:schemas:extension:enterprise:2.0:User)
and any other URN-keyed top-level extension are accepted without error, but are not
persisted as first-class fields. They are readable by
attribute-mapping rules — for example a rule reading
urn:ietf:params:scim:schemas:extension:enterprise:2.0:User.department.
Create
POST /scim/v2/Users returns 201 with the created resource. A duplicate userName in the
same tenant returns 409 with "scimType": "uniqueness".
Every returned User resource carries a meta object with resourceType (User), location,
and the created / lastModified timestamps (RFC 7643 §3.1, xsd:dateTime in UTC — e.g.
"2026-01-02T03:04:05Z"). Connectors that do incremental sync read meta.lastModified to
decide what changed since the last cycle.
Update
Two shapes, both supported:
PUT /scim/v2/Users/{id}— full replace; returns200with the updated resource.PATCH /scim/v2/Users/{id}— partial update; returns204 No Content(no body).
Supported PATCH paths (op of replace, add, or remove):
active, userName, name.givenName, name.familyName, locale, externalId, and
emails[type eq "work"].value.
phoneNumbers* paths are accepted and ignored (no error), as is any remove on an
unrecognized path — this keeps noisy IdP connectors from failing a whole sync. Any other
unrecognized path returns 400 with "scimType": "invalidSyntax". All operations in a
request are parsed and validated before any is applied, so a malformed operation rejects
the batch rather than half-applying it.
Deactivate and delete
Deactivation is the path your IdP takes when someone leaves:
active: false(viaPATCHorPUT) is a soft-disable — the user row is kept and its status becomesSUSPENDED. Federated sign-in is then refused for that user. Settingactive: trueagain restoresACTIVE; nothing else is lost. This is what Entra sends on deprovision unless the admin chose hard-delete.DELETE /scim/v2/Users/{id}returns204and moves the row to statusDELETED. It is a soft delete — the row is retained for audit and for the GDPR deletion lifecycle, which owns actual erasure and is a separate concern.
Neither path destroys the row synchronously, so a mistaken deprovision is recoverable.
List, filter, and paginate
GET /scim/v2/Users returns a SCIM ListResponse. The supported filter surface is
deliberately narrow — eq on three attributes only:
filter=userName eq "ada@example.com"
filter=externalId eq "8f14e45f-…"
filter=active eq true
Attribute names are matched case-insensitively (per RFC 7644). Anything else — another
operator (sw, co, pr, gt, …), AND / OR / NOT, complex value filters, or a
malformed expression — returns 400 with "scimType": "invalidFilter". This covers what
Okta and Entra actually send during a sync; a broader operator surface is not implemented.
Pagination uses SCIM's 1-based startIndex plus count (default 20 on /Users, 100 on
/Groups). startIndex is resolved to a page boundary, not an arbitrary offset: the
server computes the page as (startIndex - 1) / count. Walk pages on count boundaries
(1, count + 1, 2 × count + 1, …) — which is what SCIM clients do — and paging is exact.
ServiceProviderConfig advertises a filter maxResults of 200.
Attribute mapping
When the SCIM token carries a federation_member_id, per-connection mapping rules rewrite
incoming SCIM attributes onto Thoryn user fields before the write. Rules are managed over an
administrative API on identity-service, /admin/connections/{connectionId}/mappings
(GET, POST, PATCH, DELETE), and require the admin scope — this is not a tenant
console surface today.
A rule maps a source attribute to a target field. Recognized targets — email,
given_name, family_name, locale — override the value SCIM would otherwise supply.
Source names available to a rule include the flattened SCIM attributes (userName,
emails[0].value, mail, name.givenName, givenName, name.familyName, sn,
familyName, locale) and every extension URN key, both whole (urn:…:User) and dotted into
its members (urn:…:User.department).
Mapping applies on POST, PUT, and PATCH, so a rule change takes effect on each user's
next sync — no re-provision needed.
Group provisioning
/scim/v2/Groups is shipped and maps a SCIM group to an organization. It supports
POST, GET (list and by id), PUT, PATCH, and DELETE.
| SCIM attribute | Maps to |
|---|---|
displayName | The organization's name. Filterable with displayName eq "…" (the only supported group filter). |
members[].value | An identity-service users.id UUID. |
members[].display | The member's email (read-only, server-populated). |
PATCH /scim/v2/Groups/{id} supports membership churn in both shapes IdPs send:
addwith"path": "members"and a value array of member objects;removewith"path": "members"and a value array, or the filter form"path": "members[value eq \"<userId>\"]".
It returns 200 with the updated group. Unrecognized operations are ignored rather than
rejected.
Which store backs a group
This is the one part of the SCIM surface with a configuration switch, and today's default matters:
oauthy.identity.scim.org-store | Backing store | Status |
|---|---|---|
local | The identity-service organisations table. | The default — what ships today. |
product-api | The product-api B2B organization model (the single org system of record), reached through a cluster-internal facade. | Implemented, off by default, not enabled in any shipped values file. |
The SCIM wire contract is identical either way — same URL, schema, media type, verbs,
filter, pagination, and group id / member value shapes. Only the store behind the
controller changes, so no IdP re-configures its connector when the switch flips.
The product-api mode is not yet enabled anywhere. It carries prerequisites — a seeded
scim_tenant_alias row mapping the SCIM tenant_id to the hub tenant, plus a one-time
idempotent backfill on startup — and its design (ADR
2026-07-16-b2b-organizations-scim-unification.md) is still Proposed. It also narrows
group reads: only members that identity-service provisioned appear in the SCIM view. Members
added through another federation or the console are omitted, which is the correct SCIM
semantic but is an observable difference. The mechanics, the subject and tenant translations,
and the phased cutover are documented in
B2B Organizations → SCIM /Groups unification.
Until that switch flips, a SCIM group is an identity-service organization and is not the same entity as a product-api B2B organization. If you are modelling business customers, read B2B Organizations — those are created over the product-api Organizations API, not over SCIM.
Projecting directory attributes onto defined keys (customer plane)
The identity-service attribute mapping above maps SCIM attributes onto
the Thoryn user fields (email, name, …) at provision time. There is a second, separate
mapping layer on the product-api side of Directory Sync: as a directory member is
provisioned, product-api lands the inbound attribute bag on the member's custom_attributes.
By default that bag is stored opaque. A directory attribute mapping lets a tenant admin
project a named inbound IdP attribute onto one of the organization's
defined attribute keys — so department from the IdP lands on your
defined team key.
The mapping is optional and opt-in per directory: a directory with no mappings behaves
exactly as before — the raw attribute bag is stored unchanged. When a mapping is configured,
for each sourceAttribute present in the inbound bag its value is written onto
targetAttributeKey, and every unmapped attribute is preserved verbatim (the source
attribute is kept too — projection copies, it never deletes). The projected bag is then
validated and coerced against the org's effective user schema by the same write-path
validation the interactive user-metadata API uses.
The targetAttributeKey is validated at create time against the organization's effective
user schema (general ∪ per-org defined attributes). A key that is not defined is rejected
with 422 unknown_target_attribute_key, so a mapping can never point at a non-existent
attribute.
These endpoints live on the customer plane under /api/v1/directories/{directoryId}/… and
reuse the existing tenant:orgs.read / tenant:orgs.write scopes — directory config is
per-org, so no new scope is minted. Every operation is tenant- and environment-scoped; a
cross-tenant or cross-environment directoryId resolves to 404 (never 403) — no
existence leak. Errors are RFC 9457 problem-details.
| Method | Path | Scope | Description |
|---|---|---|---|
POST | /api/v1/directories/{directoryId}/attribute-mappings | tenant:orgs.write | Map sourceAttribute → targetAttributeKey (201; 409 duplicate source; 422 unknown target key; 404 unknown directory). |
GET | /api/v1/directories/{directoryId}/attribute-mappings | tenant:orgs.read | List a directory's mappings. |
DELETE | /api/v1/directories/{directoryId}/attribute-mappings/{id} | tenant:orgs.write | Remove a mapping (204; 404 if missing). |
At most one mapping exists per (directory, sourceAttribute) — a repeat is 409 mapping_exists.
Endpoint reference
Base URL: https://<identity-host>/scim/v2. Media type application/scim+json.
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /scim/v2/ServiceProviderConfig | public | Supported features (filter, patch; no bulk, no sort, no ETag). |
GET | /scim/v2/Schemas | public | The User and Group schemas. |
GET | /scim/v2/Schemas/{id} | public | One schema by URN (404 if unknown). |
GET | /scim/v2/ResourceTypes | public | The User and Group resource types. |
GET | /scim/v2/ResourceTypes/{name} | public | One resource type (404 if unknown). |
POST | /scim/v2/Users | scim | Create a user (201; 409 uniqueness on duplicate userName). |
GET | /scim/v2/Users | scim | List users (filter, startIndex, count). |
GET | /scim/v2/Users/{id} | scim | Get one user (404 if missing or another tenant's). |
PUT | /scim/v2/Users/{id} | scim | Replace a user (200). |
PATCH | /scim/v2/Users/{id} | scim | Partial update (204, no body). |
DELETE | /scim/v2/Users/{id} | scim | Soft-delete — status DELETED (204). |
POST | /scim/v2/Groups | scim | Create a group (201). |
GET | /scim/v2/Groups | scim | List groups (displayName eq filter, startIndex, count). |
GET | /scim/v2/Groups/{id} | scim | Get one group with members (404 if missing). |
PUT | /scim/v2/Groups/{id} | scim | Replace name + exact membership (200). |
PATCH | /scim/v2/Groups/{id} | scim | Add / remove members (200). |
DELETE | /scim/v2/Groups/{id} | scim | Delete a group (204). |
GET / POST / PATCH / DELETE | /admin/connections/{connectionId}/mappings | admin | Per-connection attribute-mapping rules (administrative, not a console surface). |
Discovery endpoints are public by design (RFC 7644 §1.3.1) — they describe capabilities and expose no tenant data.
Errors are SCIM error objects (urn:ietf:params:scim:api:messages:2.0:Error) carrying
status, detail, and where applicable a scimType — not the RFC 9457 problem-details
shape the customer-plane APIs use. SCIM clients expect the SCIM error schema, so this surface
keeps it. This holds for unexpected server-side failures too: they are reported as a SCIM
error object with status 500, never as an empty-bodied response.
detail is written for a human debugging a connection and is deliberately incurious about
your token: it never quotes claim values, tenant ids, or internal exception text. When a
detail is too generic to act on, the Troubleshooting table below maps
each status / scimType pair to its real cause.
Configuration
oauthy:
identity:
scim:
# Which store backs /scim/v2/Groups. `local` (default) = the identity-service
# `organisations` table; `product-api` = the B2B organization system of record.
org-store: local
org-unification:
# Only read when org-store=product-api. In-cluster Service URL — never the
# external gateway (this is a server-to-server call).
product-api-base-url: http://thoryn-product-api:8082
request-timeout: PT5S
# One-time idempotent backfill of existing groups/memberships into the SoR.
backfill-enabled: true/Users needs no configuration beyond the token — it is on unconditionally.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
401 on every SCIM request | The token is absent, expired, or not signed by the hub. /scim/v2/** validates against the hub's JWKS — an identity-service-issued token is not accepted. |
403 with a valid token | The token lacks the scim scope. Check the registered client's scope grant. |
403 on every resource request from one connection, discovery fine | The token is missing the tenant_id claim, so it is not scoped to a tenant. It is required and never defaulted — add the static claims-mapping rule to the client. (The detail deliberately does not name the claim; this row is the map.) Before SSO-1990 this surfaced as a bare 500 with no body. |
409 with "scimType": "uniqueness" on create | A user with that userName already exists in this tenant. Have the IdP match on userName eq first, then PATCH instead of POST — a normal state after a re-sync. |
400 with "scimType": "invalidFilter" | The filter uses an unsupported operator or combinator. Only eq on userName, externalId, active (Users) and displayName (Groups) is supported. |
400 with "scimType": "invalidSyntax" on PATCH | An unsupported path. See the supported list above; phoneNumbers* is ignored, not rejected. |
400 with "scimType": "invalidValue" on a single-resource path | The {id} in the path is not a UUID. Thoryn ids are UUIDs — make sure the IdP is sending the id Thoryn returned, not its own externalId. |
400 with "scimType": "invalidSyntax" on create / replace | The request body is not parseable JSON. Check the connector's Content-Type — application/scim+json and application/json are both accepted. |
| Results come back in an order you did not ask for | Sorting is not implemented and ServiceProviderConfig says so (sort.supported: false). sortBy / sortOrder are ignored rather than rejected. Page with startIndex / count and sort client-side. |
404 on a user you can see in your IdP | The user belongs to another tenant, or was never provisioned to this connection. Cross-tenant reads return 404 by design — no existence leak. |
Users provisioned but department (or another extension attribute) is absent | Extensions are accepted but not persisted as fields. Add an attribute-mapping rule, and make sure the token carries federation_member_id — mapping is skipped without it. |
| Attribute-mapping rules have no effect | Same cause: no federation_member_id claim on the SCIM token. |
| A deprovisioned user can still sign in | Confirm the IdP sent active: false (some connectors are configured to do nothing on unassign). A SUSPENDED user is refused at federated sign-in. |
| Paging returns overlapping or skipped users | startIndex is resolved to a page boundary ((startIndex - 1) / count). Request pages on count boundaries. |
Group members missing from a GET after switching to product-api | Expected — only identity-service-provisioned members appear. See the unification narrowing. |
Security notes
- The SCIM token is a standing, high-privilege credential held by a third party (your IdP).
It can create, modify, and deactivate every user in its tenant. Scope it to
scimand nothing else, rotate it on the same schedule as your other provisioning secrets, and revoke it the moment a connection is retired. - Tenant isolation rides the
tenant_idclaim. Every read and write is scoped to it, and a cross-tenant reference returns404rather than403— no existence leak. A cross-tenant attempt writes ascim.user.cross_tenant_deniedaudit event. - Every mutation is audited.
scim.user.create,scim.user.update,scim.user.disable, andscim.user.deleteare emitted with the tenant, the user id, theexternalId, and the acting client — so a deprovisioning can always be attributed to a connection. - Deactivation is soft by default, and
DELETEis a soft-delete. Actual erasure is the GDPR deletion lifecycle's job. A misconfigured IdP cannot destroy your directory in one sync. - There is no SCIM-specific rate limit. A misbehaving connector is bounded by the gateway's edge limits, not by a per-connection SCIM cap. Prefer your IdP's own throttle settings for large initial syncs.
- Discovery is public but data is not.
ServiceProviderConfig,Schemas, andResourceTypesare static capability documents; every resource endpoint requires the token. - The
/internal/scim/organizationsfacade that backsorg-store=product-apiis cluster-internal and network-isolated — it is not routable from the gateway and is never a tenant-facing endpoint.
Related
- Add a federation member — the sign-in half: let those provisioned users authenticate through their own IdP.
- B2B Organizations — the product-api organization model, and the SCIM
/Groupsunification design. - Authorize with roles, permissions, and relationships — granting provisioned users roles and permissions.