Skip to content

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:

  1. Point the IdP's SCIM connector at the base URLhttps://<identity-host>/scim/v2. /Users and /Groups are co-located under that one base URL, which is what Okta and Entra expect.
  2. Give it a bearer token — a hub-issued OAuth token carrying the scim scope and a tenant_id claim (see The bearer token).
  3. Map attributes — the IdP's default SCIM mapping works out of the box; per-connection overrides are available (see Attribute mapping).
  4. 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:

RequirementWhy
Scope scimAuthorizes the /scim/v2/** surface. Any other token is 403.
A tenant_id claimScopes 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 attributeThoryn user fieldNotes
userNameemailThe canonical identifier. Must be unique per tenant.
emailsemailTakes 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.
externalIdexternal_idYour IdP's stable id (Entra sends the directory oid). Blank and the literal string "null" both normalize to "no externalId".
name.givenNamegiven_name
name.familyNamefamily_name
localelocale
activestatustrueACTIVE, falseSUSPENDED. Honoured on create as well as update.
passwordpasswordOptional 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; returns 200 with the updated resource.
  • PATCH /scim/v2/Users/{id} — partial update; returns 204 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 (via PATCH or PUT) is a soft-disable — the user row is kept and its status becomes SUSPENDED. Federated sign-in is then refused for that user. Setting active: true again restores ACTIVE; nothing else is lost. This is what Entra sends on deprovision unless the admin chose hard-delete.
  • DELETE /scim/v2/Users/{id} returns 204 and moves the row to status DELETED. 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 attributeMaps to
displayNameThe organization's name. Filterable with displayName eq "…" (the only supported group filter).
members[].valueAn identity-service users.id UUID.
members[].displayThe member's email (read-only, server-populated).

PATCH /scim/v2/Groups/{id} supports membership churn in both shapes IdPs send:

  • add with "path": "members" and a value array of member objects;
  • remove with "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-storeBacking storeStatus
localThe identity-service organisations table.The default — what ships today.
product-apiThe 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.

MethodPathScopeDescription
POST/api/v1/directories/{directoryId}/attribute-mappingstenant:orgs.writeMap sourceAttributetargetAttributeKey (201; 409 duplicate source; 422 unknown target key; 404 unknown directory).
GET/api/v1/directories/{directoryId}/attribute-mappingstenant:orgs.readList a directory's mappings.
DELETE/api/v1/directories/{directoryId}/attribute-mappings/{id}tenant:orgs.writeRemove 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.

MethodPathAuthDescription
GET/scim/v2/ServiceProviderConfigpublicSupported features (filter, patch; no bulk, no sort, no ETag).
GET/scim/v2/SchemaspublicThe User and Group schemas.
GET/scim/v2/Schemas/{id}publicOne schema by URN (404 if unknown).
GET/scim/v2/ResourceTypespublicThe User and Group resource types.
GET/scim/v2/ResourceTypes/{name}publicOne resource type (404 if unknown).
POST/scim/v2/UsersscimCreate a user (201; 409 uniqueness on duplicate userName).
GET/scim/v2/UsersscimList users (filter, startIndex, count).
GET/scim/v2/Users/{id}scimGet one user (404 if missing or another tenant's).
PUT/scim/v2/Users/{id}scimReplace a user (200).
PATCH/scim/v2/Users/{id}scimPartial update (204, no body).
DELETE/scim/v2/Users/{id}scimSoft-delete — status DELETED (204).
POST/scim/v2/GroupsscimCreate a group (201).
GET/scim/v2/GroupsscimList groups (displayName eq filter, startIndex, count).
GET/scim/v2/Groups/{id}scimGet one group with members (404 if missing).
PUT/scim/v2/Groups/{id}scimReplace name + exact membership (200).
PATCH/scim/v2/Groups/{id}scimAdd / remove members (200).
DELETE/scim/v2/Groups/{id}scimDelete a group (204).
GET / POST / PATCH / DELETE/admin/connections/{connectionId}/mappingsadminPer-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 scimTypenot 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

SymptomCause / fix
401 on every SCIM requestThe 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 tokenThe token lacks the scim scope. Check the registered client's scope grant.
403 on every resource request from one connection, discovery fineThe 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 createA 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 PATCHAn unsupported path. See the supported list above; phoneNumbers* is ignored, not rejected.
400 with "scimType": "invalidValue" on a single-resource pathThe {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 / replaceThe request body is not parseable JSON. Check the connector's Content-Typeapplication/scim+json and application/json are both accepted.
Results come back in an order you did not ask forSorting 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 IdPThe 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 absentExtensions 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 effectSame cause: no federation_member_id claim on the SCIM token.
A deprovisioned user can still sign inConfirm 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 usersstartIndex is resolved to a page boundary ((startIndex - 1) / count). Request pages on count boundaries.
Group members missing from a GET after switching to product-apiExpected — 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 scim and 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_id claim. Every read and write is scoped to it, and a cross-tenant reference returns 404 rather than 403 — no existence leak. A cross-tenant attempt writes a scim.user.cross_tenant_denied audit event.
  • Every mutation is audited. scim.user.create, scim.user.update, scim.user.disable, and scim.user.delete are emitted with the tenant, the user id, the externalId, and the acting client — so a deprovisioning can always be attributed to a connection.
  • Deactivation is soft by default, and DELETE is 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, and ResourceTypes are static capability documents; every resource endpoint requires the token.
  • The /internal/scim/organizations facade that backs org-store=product-api is cluster-internal and network-isolated — it is not routable from the gateway and is never a tenant-facing endpoint.