Your B2B SaaS already supports SSO. Enterprise prospects log in with Azure Entra ID or Auth0, corporate credentials and all. And then their IT admin still has to create every single user account by hand inside your application, assign each one to the right groups, and remember to remove them again when someone leaves. That gap is where SCIM comes in.

SSO answers the question "who is this person?". SCIM answers a different one: "should this person have an account at all, and is it still up to date?" SSO is authentication. SCIM is the whole user lifecycle. Enterprise buyers expect both, and they will ask for both on the same vendor security assessment.

An IT admin configuring automated SCIM user provisioning between an identity provider and a .NET application
Configure provisioning once in the identity provider. After that, accounts stay in sync on their own.

What is SCIM provisioning?

SCIM stands for System for Cross-domain Identity Management. It is an open standard for automating user provisioning. In practice it defines a REST API that identity providers like Azure Entra ID and Auth0 call to create, update, and deactivate user accounts in your application.

The current version is SCIM 2.0, defined in RFC 7642, RFC 7643, and RFC 7644. It specifies:

  • User and Group resources with a standard JSON schema
  • CRUD operations via REST endpoints (/Users, /Groups)
  • Filtering and pagination for listing resources
  • Bulk operations for the initial provisioning of large user bases

Why do enterprise customers require SCIM?

Picture a company with 2,000 employees adopting your SaaS without SCIM. The IT admin creates 2,000 accounts by hand, or uploads a CSV if you happened to build that. After that, it is all manual. New hires, department changes, people walking out the door on their last day: someone has to remember to reflect each of those in your app. When the security audit asks how offboarded employees lose access, the honest answer is "we hope the IT admin remembers."

That answer used to be tolerated. It is not anymore. Prompt, auditable removal of access when someone leaves is now an explicit control in the standards large enterprises are certified against: SOC 2 (criteria CC6.2 and CC6.3, where auditors pull a sample of leavers and expect access gone within about one business day), ISO 27001 (Annex A, removal of access rights on termination), NIST 800-53 (AC-2 account management and PS-4 personnel termination), and PCI DSS (terminated users revoked promptly). Your enterprise customer carries those obligations, so they push them onto you through the same vendor security assessment. "We rely on their admin to remember" is the answer that stalls the deal.

With SCIM in place, the same story reads very differently:

  • The IT admin configures SCIM provisioning once, in Azure Entra ID or Auth0.
  • A new hire shows up in your app within minutes, provisioned automatically.
  • When someone moves departments, their group membership updates on its own.
  • When someone leaves, their account is deactivated without anyone touching your app.
  • The security audit gets a clean answer: the IdP owns the user lifecycle, and provisioning runs through SCIM.

SSO handles authentication. The user lifecycle is SCIM's job. Enterprise buyers expect both, which is why the two sit next to each other on the same vendor security assessment.

How does SCIM work technically?

Your .NET application exposes a SCIM API, and the customer's identity provider calls it to manage users. The provider authenticates with a bearer token (OAuth2) or a long-lived API token. Azure Entra ID and Auth0 support both. The core endpoints are:

Method Endpoint Purpose
POST/scim/v2/UsersCreate a new user
GET/scim/v2/Users/{id}Get a specific user
GET/scim/v2/Users?filter=...Search users (for example by email)
PATCH/scim/v2/Users/{id}Update user attributes
DELETE/scim/v2/Users/{id}Deactivate or remove a user
POST/scim/v2/GroupsCreate a group
PATCH/scim/v2/Groups/{id}Add or remove group members

That table is the core set, not the whole list. A compliant SCIM service also exposes discovery endpoints that the identity provider queries before it provisions anyone: GET /ServiceProviderConfig (which features you support), plus GET /Schemas and GET /ResourceTypes (the exact shape of your Users and Groups). Some clients also use PUT to replace a whole resource where Entra leans on PATCH, and the spec defines an optional /Bulk endpoint. Counting Users, Groups and discovery, a properly compliant endpoint answers well over a dozen distinct request types.

A SCIM user resource is plain JSON with a fixed schema. A create request coming from the identity provider looks roughly like this:

POST /scim/v2/Users
Authorization: Bearer <token>
Content-Type: application/scim+json

{
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:User",
    "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
  ],
  "userName": "john@example.com",
  "name": { "givenName": "John", "familyName": "Doe" },
  "displayName": "John Doe",
  "emails": [{ "value": "john@example.com", "type": "work", "primary": true }],
  "externalId": "8f14e45f-ceea-467a-9f0e-1c2b3d4e5f60",
  "active": true,
  "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
    "department": "Sales",
    "employeeNumber": "12345"
  }
}

How do you build SCIM in ASP.NET Core?

There is no official, supported Microsoft library for a SCIM server in .NET. A few community NuGet packages exist, and Microsoft published reference code (the Microsoft.SCIM projects in the SCIMReferenceCode repo), but none became the de-facto standard you can lean on the way you lean on ASP.NET Core Identity. Be clear about what that Microsoft code is: a reference sample to read and copy from, not a maintained NuGet package you take a dependency on. So you either build it yourself with standard controllers and middleware, or adopt an immature package and own its gaps.

And a working controller is maybe 5% of the job. It is not the code that is hard, it is everything the code has to survive once a real customer's identity provider starts calling it. That is where SCIM stops being a REST tutorial and starts asking for someone who has shipped it before. Here is what actually eats the time.

You need a real filter parser

Before creating anyone, the identity provider asks your API whether that user already exists. It does that with a SCIM filter, and SCIM filters are a small OData-like query language defined in RFC 7644, not a string you can match with a few if statements. The operators include eq, ne, co, sw, ew, pr, gt, ge, lt and le, combined with and, or, not and grouping. Multi-valued attributes carry their own bracketed sub-filters:

userName eq "john@example.com"
emails[type eq "work"].value
emails[primary eq true].value
meta.lastModified gt "2026-01-01T00:00:00Z"
name.familyName co "Doe" and active eq true

Azure Entra ID leans on a small corner of this in practice, mostly userName eq and externalId eq. But other SCIM clients (Okta, OneLogin, Auth0) exercise different operators and more complex expressions, all valid per the RFC, so building only for what Entra happens to send means breaking for the next customer's identity provider. The same grammar also turns up inside PATCH paths (emails[type eq "work"].value, members[value eq "..."]). A proper parser is not optional. String-matching gets you through the demo and breaks in production.

Azure Entra ID does not send what the RFC says

This is the part that surprises people. Entra's provisioning client has documented deviations from the spec, and the default behaviour is the non-compliant one. To deactivate a user, Entra by default sends active as the string "False" with a capitalised op:

{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
  "Operations": [
    { "op": "Replace", "path": "active", "value": "False" }
  ]
}

A strict parser that expects a boolean and a lowercase op rejects that, and the user never gets deactivated. There is a feature flag (aadOptscim062020, set in the tenant URL) that switches Entra to compliant requests: lowercase op, a real boolean false, and a different shape for removing group members. The catch is that you cannot count on a customer enabling it, so your endpoint has to accept both shapes. A few more you tend to discover the hard way: Entra insists on /scim at the root of the endpoint URL, older tenants run a legacy customappsso job with its own quirks, and it URL-encodes quotes in filters in a way some stacks reject.

Attribute mapping is its own small project

SCIM defines a core user schema plus an enterprise extension, and Entra exposes a mapping UI where the admin decides which directory attribute lands in which SCIM field. That mapping has to line up exactly with what your endpoint expects, extension attributes included (urn:ietf:params:scim:schemas:extension:enterprise:2.0:User). Get one wrong and values land silently in the wrong field. It is fiddly, customer-specific work, and it is usually where an onboarding stalls.

Mapping the externalId: where provisioning and SSO meet

The externalId is the identity provider's own stable identifier for the user, separate from the SCIM id your service assigns and hands back. The mistake is not ignoring it, it is treating it as just another attribute. It is a correlation key: persist it, index it, and match incoming requests on it, not on userName or email, which both change over time.

And this is the crux of the whole integration. That same externalId has to line up with the identifier you receive at SSO login, so the account SCIM provisioned is recognised as the same person when they sign in. With Entra that key is often the objectId (the oid claim, which Entra also sends as externalId), but it can instead be a functional key such as an employee number, as long as you map externalId to it on the SCIM side and configure the token to emit the matching claim on the login side. A functional key is more portable and survives account or even tenant changes, but only if everyone has one, it is unique, and it is never reused. Either way, provisioning and login have to key off the same value. Change one side and forget the other, and a fully provisioned user signs in to a blank account, with none of the access you set up for them.

Group membership has sharp edges

Groups sync through PATCH operations that add and remove members, and Entra sends member removals in two different shapes depending on that same feature flag. Large groups arrive paginated, and a single membership change on a big group can mean a long run of operations. You handle the add path, both remove shapes, and the paging, or group-based access in your app quietly drifts out of step with the directory.

Real-scale testing through Entra is possible, but slow and expensive

The provisioning service runs on a roughly 40-minute cycle and moves on the order of 5,000 users per hour, and Entra throttles writes (around 7,000 per five minutes). That cycle alone makes iterating painful: change something, and you wait up to 40 minutes to see what the next run does.

You also do this in a dedicated test tenant, never your company's production directory. And the provisioning service needs Entra ID P1, licensed per user at around $6 per user per month (the free trial gives you roughly 100 licences for 30 days, fine for functional checks but nowhere near a scale population). Even then, you are not going to stand up a 10,000-user tenant and click "provision" to test: that is hours per run, and around $60,000 a month in P1 licences at that headcount.

So you split it in two. A real Entra test tenant confirms you handle its actual request shapes correctly, which is what those roughly 100 trial users are for, since the portal's built-in test tool only covers the single-user happy path. A simulation then confirms you handle them at volume: replay the exact create, PATCH and group calls Entra produces, at scale, against your endpoint, and assert it stays correct and stays up. Treat that simulation as a load test of your hot paths: the filter lookup that runs before every create, the create itself on first sync, PATCH for activate and deactivate, and group-membership PATCH on large groups. That is also how you find where your rate limit has to sit, so a customer's opening sync cannot take the rest of your application down with it. Small and functional through Entra, large and at scale through simulation.

Sync has to recover on its own, or it quietly stops

Your endpoint will be down at some point: a deploy, a database blip, a bad release, a long maintenance window. And SCIM gives you no free "catch up later". Entra keeps trying, but once a large share of requests fail, it puts the whole provisioning job into quarantine, and the retry schedule stretches out fast: 6 hours, then 12, then once every 24 hours, for 28 days, after which Entra disables the job entirely. While that runs, your incremental sync slows to once a day. Now hold that against the audit promise from earlier: a leaver is supposed to lose access within a day, but the directory cannot even reach you to deactivate them.

So the endpoint has to be safe to re-drive. Every create, update and deactivate has to be idempotent, so a replayed cycle reconciles cleanly instead of duplicating or corrupting records. After downtime you want the next cycle, or a forced restart of the provisioning job, to bring everyone back into step with no manual cleanup. Staying in sync is not the happy path you demo. It is the failure path you have to design for.

One tip that pays for itself: log every request

Log every SCIM request to a database, not just to your app logs: method, path, filter and body, exactly as it arrived. When an onboarding breaks, and one eventually will, the only way to see what Entra actually sent versus what the RFC promised is to have the raw request stored and queryable. Entra keeps its own provisioning logs, but those sit in the customer's tenant, which you cannot see, so the request as it hit your endpoint only exists if you captured it yourself. App logs rotate away and get sampled; a requests table you can query per tenant is the difference between a five-minute fix and a two-day guessing game.

And roles are a separate problem on top of all that. For everything above, users and groups at least have a first-class place in the SCIM standard to build against. Roles do not. SCIM has no universally adopted role model, so most teams map an incoming group to a role inside their application, or lean on the enterprise user extension. Azure Entra ID makes this especially awkward: app roles live in a separate corner of Entra (appRoleAssignments) from the user and group sync, so getting them to flow over SCIM takes custom attribute-mapping work in the provisioning UI. Provider support for role syncing varies, so decide early how a role flows from the IdP into your app and test that path against the actual identity provider your customer runs.

What are the common SCIM pitfalls?

  • Hard-deleting on DELETE. In SCIM, DELETE removes the resource, while deactivation is a PATCH that sets active = false. Entra offboards with active = false and only sends a real DELETE when a user is permanently purged, so the safe default is to soft-delete either way: deactivate and keep the row. A hard row delete throws away the customer's data and history you will wish you had kept, and it makes re-provisioning the same person later messy.
  • Supporting only one update verb. Azure Entra ID leans on PATCH. Auth0 uses PUT and PATCH both. So you implement both.
  • Missing error responses. SCIM defines specific error response formats, and identity providers parse them to show meaningful errors to IT admins. Return proper SCIM error objects. A generic 500 leaves the admin staring at a useless message.
  • No rate limiting. When an enterprise first switches SCIM on, the IdP syncs every existing user in one go. Without rate limiting, that opening burst can knock your application over.

How do SSO and SCIM fit together?

SCIM works best sitting alongside SSO over OpenID Connect. In a typical enterprise setup the responsibilities split cleanly:

  • SCIM provisions user accounts and keeps them in sync.
  • SSO (OpenID Connect) handles login, which means no passwords ever live in your application.

Wire both together and the customer's IT admin configures everything in one place, whether that is Azure Entra ID Enterprise Applications or Auth0 Application Integration. Users get provisioned automatically and sign in with their corporate credentials. Login and provisioning belong together. They look like two separate jobs, but they are one connected flow, and it works far better when it is built by someone who understands the whole picture, login and lifecycle. Treat them as two disconnected halves, and the gaps only surface in production, once a real customer's identity provider is the one calling your endpoint.

None of this is exotic. There is just a lot of it, and most of it only surfaces once a real customer's tenant is pointed at your endpoint and something does not line up. That is exactly the kind of work that goes faster, and breaks less, with someone who has already shipped a few of these.

Does your .NET application need to pass a vendor security assessment? Which piece are you missing first, the provisioning or the sign-in?