Articles · Enterprise SSO & Federation
SCIM provisioning
Try out the running example
Enterprise deals come with a checkbox: when we deactivate an employee in our directory, their account in your product dies too. That checkbox is SCIM 2.0 — the customer’s IdP (Okta, Azure AD, anything) pushes Users and Groups at your API. Sentinel ships the server side of that contract, and it is deliberately standalone: no login stack, no signing keys, no database required to stand it up.
Two calls
// file: ProvisioningComposition.cs
services.AddRouting();
// Register the store BEFORE AddSentinelScim(): every Sentinel registration is TryAdd,
// so the host's instance wins and stays reachable for seeding and assertions.
// A real deployment registers AddSentinelEfCoreStores instead and gets EfScimStore.
var store = new InMemoryScimStore();
ProvisioningWorld.Seed(store);
services.AddSingleton(store);
services.AddSingleton<IScimStore>(store);
// Defaults: BaseUrl "/scim/v2", MaxPageSize 100.
services.AddSentinelScim();
and in the pipeline, app.MapSentinelScim(). That mounts discovery
(/scim/v2/ServiceProviderConfig, /ResourceTypes, /Schemas), /Users and /Groups
with the full verb set, every response application/scim+json. ScimServerOptions has
exactly two knobs — BaseUrl and MaxPageSize — because the interesting configuration
lives elsewhere: in the tokens.
The sct_ token is the tenant boundary
There is no session, no OAuth dance. A SCIM caller authenticates with a bearer token minted per organization:
// file: ProvisioningWorld.cs
await using var scope = services.CreateAsyncScope();
var tokens = scope.ServiceProvider.GetRequiredService<ScimTokenService>();
var acme = await tokens.CreateAsync(RealmId, AcmeId, "acme-idp provisioning");
var globex = await tokens.CreateAsync(RealmId, GlobexId, "globex-idp provisioning");
CreateAsync returns a ScimTokenCreated whose Secret — sct_ + 32 random bytes,
base64url — is shown exactly once. The store keeps only the SHA-256 digest, so a
leaked database doesn’t leak provisioning access. (ScimTokenService is scoped, not
singleton, because the EF-backed store is DbContext-bound — hence the explicit scope.)
Everything else about the token is a consequence of one design decision: the org context comes solely from the token. There is no org in the path, no header, no query parameter. A token minted for Acme physically cannot address Globex — the question never even parses.
Failures are uniform: missing header, malformed token, unknown token, revoked token,
expired token — all answer the same 401 with WWW-Authenticate: Bearer realm="scim" and
byte-identical bodies. The sample’s Missing_garbage_and_revoked_tokens_answer_an_identical_401
pins that, revocation included. Anti-enumeration applies to machines too.
The surface, honestly scoped
Sentinel implements the subset real IdPs actually send, and answers 501 — not a silent
wrong answer — for the rest. ServiceProviderConfig declares it: patch.supported: true,
filter.supported: true, bulk/sort/etag false.
POST /scim/v2/Users
Authorization: Bearer sct_…
Content-Type: application/scim+json
{ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "grace@acme.sample", "displayName": "Grace Hopper", "externalId": "okta|00u1" }
The 201 echoes the resource with id, active: true, meta.location, and the normalized
userName — it is the user’s email, trimmed and lowercased. Filtering supports the one
clause provisioning engines emit — userName eq "…" or externalId eq "…" — and
anything fancier (co, sw, and, groups by anything but displayName) gets a 501
with a message saying exactly what is supported.
PATCH follows the dialect Azure AD and Okta speak, quirks included: the key is
capital-O Operations, users accept replace only, active tolerates the string
"True"/"False" shape Azure AD sends, and group membership moves via add/remove
with the filtered path form:
{ "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [ { "op": "remove", "path": "members[value eq \"<user-id>\"]" } ] }
Group_membership_flows_through_patch_add_and_remove walks both directions.
DELETE is a deactivation
DELETE /scim/v2/Users/{id} → 204
GET /scim/v2/Users/{id} → 200, "active": false
SCIM DELETE sets UserStatus.Deactivated and keeps the row. Users anchor audit
history — article 012 is built on that — so erasing
PII is a separate, deliberate flow (crypto-shredding, see compliance),
not a side effect of an IdP sync. The deactivated user still appears in listings, still
counts toward totalResults, and PATCH active: true restores them symmetrically — which
is exactly what the IdP sends when the employee rejoins.
Delete_deactivates_instead_of_erasing_and_patch_active_true_restores pins the round trip
down to the stored UserStatus.
Groups are the opposite: DELETE /Groups/{id} is a hard delete. Groups carry no history
worth anchoring.
Two fences
The interesting multi-tenant behavior is in what a token can’t do, and the two fences point in different directions.
Isolation is org-scoped. Globex’s token asking for an Acme user’s id gets 404 — not
403. A 403 would confirm the id exists; a 404 confirms nothing. Listings and even exact
userName eq filters simply find nothing outside the token’s org:
// A_foreign_org_token_sees_nothing_and_touches_nothing
(await host.Client.SendAsync(host.Request(HttpMethod.Get, $"/scim/v2/Users/{id}", bearer: "globex")))
.StatusCode.Should().Be(HttpStatusCode.NotFound);
Group membership gets the same fence with a different code: referencing a foreign user as
a member is a 400 invalidValue — a provisioning bug worth surfacing, not an existence
oracle worth hiding (the ids in a group payload came from the caller’s own directory).
Uniqueness is realm-scoped — deliberately wider. userName is unique across the whole
realm, so creating ada@acme.sample from Globex’s token answers 409 uniqueness even
though Globex can’t see that user. This is the anti-capture rule: without it, a
compromised tenant IdP could provision an existing identity into its own org and inherit
logins meant for someone else. The sample seeds Ada as a never-SCIM-provisioned Acme
employee precisely so
UserName_uniqueness_is_realm_wide_so_an_identity_cannot_be_stolen_across_orgs can
demonstrate the refusal.
Where it plugs in
Swap InMemoryScimStore for AddSentinelEfCoreStores() and the same surface runs on your
database — the EF adapter fences users through the membership table and was written
against the same test suite. SCIM sits alongside SAML
in the enterprise story: SAML answers who is signing in, SCIM answers who should exist
at all. The enterprise capabilities page covers both in reference
form, and the generated API reference documents the
package surface.