Getting Started with SCIM User Provisioning


Requirements: Cloud SSO

Introduction

The System for Cross-domain Identity Management (SCIM) protocol gives an Identity Provider (e.g., Microsoft Entra ID or Okta) a standardized way to automatically create, update, and deactivate accounts in your application. Instead of an administrator manually managing an account in every connected application, SCIM lets the Identity Provider push those changes out over a simple REST API whenever a user is hired, changes departments, or leaves the company.

SCIM is complementary to, but distinct from, the SAML and OpenID Connect protocols also offered by Cloud SSO. Where SAMLWeb and OIDCWeb authenticate a user at sign-in time, SCIM keeps the underlying account itself in sync, often well before the user ever logs in and after they have left the organization.

Before getting started, a few relevant terms are defined below:

  • Identity Provider (or SCIM Client) - The system (e.g., Entra ID or Okta) that owns the authoritative record for a user and pushes provisioning requests to your application.
  • Service Provider - Your application, which receives and acts on those provisioning requests. This is the role implemented by the SCIMWeb and SCIMDesktop components.
  • Resource - A SCIM object being managed. The core resource types are User and Group.
  • ExternalId - An identifier assigned by the Identity Provider that should be stored alongside the resource so the same record can be recognized on subsequent requests.

The SCIMWeb and SCIMDesktop components from Cloud SSO implement the service provider side of the SCIM 2.0 protocol (as defined by RFC 7642, 7643, and 7644). Both components operate in the same way: first, parse incoming SCIM requests and fire an event so that your custom-written event handler can use the information within the SCIM request to create, look up, update, or delete the corresponding record in its own data store. Then, the component assembles the SCIM JSON response, discovery documents, and error handling required by the specification.

The SCIMWeb component is designed to be embedded within an existing web application or framework, while the SCIMDesktop component hosts its own embedded HTTP server, making it well suited to a desktop application or background service that needs to expose a SCIM endpoint on its own. Though this article mainly focuses on the SCIMWeb component, the two share the same event-driven API; see the SCIMDesktop Example at the end of the article for the differences.

Contents

How SCIM Works

SCIM defines a small set of resource endpoints, all rooted beneath a base URL (conventionally /scim/v2). The standard HTTP methods map to the following operations on each resource type.

Endpoint Purpose
POST /Users, POST /Groups Create a new resource.
GET /Users/{id}, GET /Groups/{id} Retrieve a single resource.
GET /Users, GET /Groups Search or list resources, with pagination and optional filtering.
PUT /Users/{id}, PUT /Groups/{id} Replace a resource in its entirety.
PATCH /Users/{id}, PATCH /Groups/{id} Apply a partial update to specific attributes.
DELETE /Users/{id}, DELETE /Groups/{id} Remove a resource.
GET /ServiceProviderConfig, GET /ResourceTypes, GET /Schemas Discovery endpoints describing the service provider's capabilities.

The SCIMWeb and SCIMDesktop components handle parsing the request, routing it to the correct resource type and operation, applying PATCH semantics, and serializing the JSON response, so your application only needs to implement the underlying data access. Each incoming request is authenticated (see Authenticating Requests), then routed to fire a matching event, such as CreateUser or ListGroups, giving your application full control over how resources are stored.

Choosing a Component

Two components implement the service provider role:

  • SCIMWeb is embedded within an existing web application or framework. Call the ProcessRequest method from your framework's request handler whenever a request arrives at the SCIM base URL. This is the correct choice when your application is already running behind an ASP.NET Core, Java, or similar web server.
  • SCIMDesktop hosts its own embedded HTTP server, so no external web server is required. Call the StartListening method to begin accepting connections. This is well suited to a desktop application or background service that needs to expose a SCIM endpoint on its own.

Basic Setup

Regardless of which component is used, a few properties control how requests are routed and what the service provider advertises about itself.

The BaseURL property is the path prefix used to recognize SCIM resource paths (defaulting to /scim/v2). The SupportedOperations property is a bitmask that controls which combinations of HTTP method and resource type are accepted (see below). The MaxResults property dictates how many resources can be returned from a single list request.

Flag Enables
sopUserCreate (0x0001) POST /Users, firing the CreateUser event.
sopUserRead (0x0002) GET /Users/{id}, firing the GetUser event.
sopUserUpdate (0x0004) PUT and PATCH /Users/{id}, firing the GetUser and UpdateUser events.
sopUserDelete (0x0008) DELETE /Users/{id}, firing the DeleteUser event.
sopUserList (0x0010) GET /Users, firing the ListUsers event.
sopGroupCreate (0x0100) POST /Groups, firing the CreateGroup event.
sopGroupRead (0x0200) GET /Groups/{id}, firing the GetGroup event.
sopGroupUpdate (0x0400) PUT and PATCH /Groups/{id}, firing the GetGroup and UpdateGroup events.
sopGroupDelete (0x0800) DELETE /Groups/{id}, firing the DeleteGroup event.
sopGroupList (0x1000) GET /Groups, firing the ListGroups event.
sopAll (0xFFFF) Every operation above (the default).

The discovery endpoints (ServiceProviderConfig, ResourceTypes, and Schemas) are always available regardless of this property's value, and the capabilities they advertise are derived from it automatically.

scim.BaseURL = "/scim/v2"; scim.SupportedOperations = SupportedOperations.sopAll; scim.MaxResults = 200;

If an application manages only users and does not support group provisioning, the group-related flags can simply be omitted from this bitmask so an Identity Provider that attempts group provisioning receives a clear 501 response instead of hitting an unimplemented event handler.

Processing Incoming Requests

ASP.NET Core

The simplest way to use the SCIMWeb component in an ASP.NET Core application is to pass the current HttpContext to its constructor, which allows the component to read the request from, and write the response to, that context automatically.

app.Map("/scim", scimApp => { scimApp.Run(async context => { var scim = new SCIMWeb(context); scim.BaseURL = "/scim/v2";
// Attach event handlers here, or once at startup if scim is reused across requests.
scim.ProcessRequest(); // The response has already been written to context. }); });

Java Servlet

In a Java servlet, the current HttpServletRequest and HttpServletResponse objects are passed directly to processRequest.

scim.processRequest(request, response);

Manual Request Handling

If the current HTTP context is not available, or when working within a framework that does not expose the raw request directly, the RequestMethod, RequestURI, RequestHeaders, and RequestBody properties may be set before calling ProcessRequest. The ResponseStatus, ResponseHeaders, and ResponseBody properties may then be read afterward to write the response. These properties are not cleared automatically after ProcessRequest returns, so a new value should be assigned to each of them before every subsequent call.

scim.RequestMethod = request.Method; scim.RequestURI = request.RawUrl; scim.RequestHeaders = GetHeaderString(request); scim.RequestBody = new StreamReader(request.InputStream).ReadToEnd();
scim.ProcessRequest();
response.StatusCode = scim.ResponseStatus; WriteHeaders(response, scim.ResponseHeaders); response.Write(scim.ResponseBody);

Authenticating Requests

Every incoming request fires the AuthRequest event before any resource-specific event is raised, giving the application the chance to accept or reject the request. The AuthMethod parameter identifies the authentication scheme supplied by the client.

AuthMethod Behavior
Bearer The scheme used by most Identity Provider integrations. The Token parameter contains the raw bearer token. If the AuthToken property has been set, the component automatically compares it against the incoming token and pre-initializes Accept with the result, so a simple deployment does not need an AuthRequest handler at all.
Basic The component decodes the base64 credentials and splits them on the colon separator. User contains the username and Token contains the password. No automatic comparison is performed.
Other/Unrecognized AuthMethod contains the scheme name as supplied by the client, and Token contains everything following it. Accept defaults to false.
(empty) No Authorization header was present. User and Token are both empty, and Accept defaults to false.

For a simple deployment where the Identity Provider is configured with a single, static secret token, it is enough to set the AuthToken property; no event handler is required.

scim.AuthToken = "the-token-issued-to-your-identity-provider";

For scenarios that require validating against a token store, enforcing expiry, or supporting Basic authentication, handle the AuthRequest event directly.

scim.OnAuthRequest += (sender, e) => { if (e.AuthMethod == "Bearer") { e.Accept = tokenStore.IsValid(e.Token); } else if (e.AuthMethod == "Basic") { e.Accept = userStore.CheckPassword(e.User, e.Token); } else { e.Accept = false; } };

Requests rejected by this event receive a 401 Unauthorized response. When using SCIMWeb behind a web server or reverse proxy that has already authenticated the request, the built-in check can be bypassed entirely by disabling the AuthEnabled configuration setting, which causes both the AuthToken property and the AuthRequest event to be skipped.

Managing Users

The component fires an event for each standard SCIM user operation, giving the application full control over how users are stored. Every event includes a RequestId parameter that may be used to correlate events fired while processing the same request.

Creating a User

A POST /Users request fires the CreateUser event with the submitted attributes. If the Identity Provider included an externalId in the request, it is available through the ExternalId parameter and should be stored alongside the user record so it can be returned in future GetUser events. The handler must assign a unique UserId and set StatusCode to 201; the full created resource is then returned to the Identity Provider automatically. The StatusCode parameter defaults to 500, and a value of 0 is treated as 201.

scim.OnCreateUser += (sender, e) => { var user = db.CreateUser(e.UserName, e.GivenName, e.FamilyName, e.DisplayName, e.Active, e.ExternalId); e.UserId = user.Id; e.StatusCode = 201; };

Retrieving a User

A GET /Users/{id} request fires the GetUser event. This event also fires internally before a PATCH update (so the current state can be merged with the incoming patch operations) and before a PUT or DELETE request when the ETagEnabled configuration setting is enabled and the request includes an If-Match header. The read-only RequestType property can be checked if the handler needs to distinguish which of these triggered the current event.

scim.OnGetUser += (sender, e) => { var user = db.FindUser(e.UserId); if (user == null) { e.StatusCode = 404; return; } e.UserName = user.UserName; e.GivenName = user.GivenName; e.FamilyName = user.FamilyName; e.DisplayName = user.DisplayName; e.ExternalId = user.ExternalId; e.Active = user.Active; e.StatusCode = 200; };

Listing and Filtering Users

A GET /Users request fires the ListUsers event instead. The Filter parameter contains the raw SCIM filter expression from the request, or is empty if none was specified. For simple single-term filters (e.g., userName eq "jsmith"), the FilterAttribute, FilterOperator, and FilterValue parameters are also populated with the parsed components for convenience; for compound or nested filter expressions, only Filter is populated and the application must parse it directly.

The StartIndex and Count parameters reflect the pagination values from the request. StartIndex is 1-based, and Count is capped by the MaxResults property (and defaults to it if the Identity Provider does not send a count parameter).

The AddUser method should be called once per matching user, after which the TotalResults parameter should be set to the total number of matching users across all pages, which may be greater than the number of entries added on this call.

scim.OnListUsers += (sender, e) => { var results = db.QueryUsers(e.FilterAttribute, e.FilterOperator, e.FilterValue, e.StartIndex - 1, e.Count); foreach (var user in results.Page) scim.AddUser(user.Id, user.UserName, user.DisplayName, user.ExternalId, user.Active);
e.TotalResults = results.TotalCount; e.StatusCode = 200; };

Updating and Deactivating a User

Both PUT /Users/{id} and PATCH /Users/{id} requests fire the UpdateUser event. For a PUT request, the event parameters reflect the full user representation from the request body directly, and any attributes not present in the request should be cleared. For a PATCH request, the component first fires the GetUser event to load the current record, applies the patch operations from the request (including path-filtered operations such as emails[type eq "work"]) internally, and then fires UpdateUser with the fully merged result; thus, the handler always sees a complete, final representation of the user regardless of which method was used.

scim.OnUpdateUser += (sender, e) => { var user = db.FindUser(e.UserId); if (user == null) { e.StatusCode = 404; return; } user.UserName = e.UserName; user.GivenName = e.GivenName; user.FamilyName = e.FamilyName; user.DisplayName = e.DisplayName; user.Active = e.Active; db.SaveUser(user); e.StatusCode = 200; };

When the update succeeds, StatusCode must be set to 200 or 204; 204 is valid when the updated resource does not need to be returned, causing a 204 No Content response with no body. Some Identity Providers require a 200 response with the updated resource body, so check the connecting Identity Provider's SCIM implementation guide before returning 204.

Deactivating a user without deleting their record is done the standard SCIM way. The Identity Provider sets the active attribute to false in a PATCH or PUT request, which the handler observes through the Active parameter. This is the mechanism most Identity Providers use to offboard a user while preserving historical data.

Deleting a User

A DELETE /Users/{id} request fires the DeleteUser event. When the deletion is successful, StatusCode must be set to 204; if the user is not found, it should be set to 404.

scim.OnDeleteUser += (sender, e) => { e.StatusCode = db.DeleteUser(e.UserId) ? 204 : 404; };

Emails, Phone Numbers, and Enterprise Attributes

Email addresses and phone numbers submitted with a CreateUser or UpdateUser request are made available through the UserEmails and UserPhones collections.

Each SCIMEmail exposes Address, EmailType (e.g., work, home, other), and Primary fields, whereas SCIMPhone exposes Number, PhoneType, and Primary.

If the request included enterprise extension attributes, they are made available through the UserExtension property, exposing Department, EmployeeNumber, CostCenter, Organization, Division, ManagerId, ManagerRef, and ManagerDisplayName. The enterprise extension schema namespace is handled internally by the component; the API exposes these attributes directly without requiring the URN to be specified.

scim.OnCreateUser += (sender, e) => { var user = db.CreateUser(e.UserName, e.GivenName, e.FamilyName, e.DisplayName, e.Active, e.ExternalId);
foreach (var email in scim.UserEmails) user.AddEmail(email.Address, email.EmailType, email.Primary); foreach (var phone in scim.UserPhones) user.AddPhone(phone.Number, phone.PhoneType, phone.Primary); if (!string.IsNullOrEmpty(scim.UserExtension.Department)) user.Department = scim.UserExtension.Department;
e.UserId = user.Id; e.StatusCode = 201; };

To include this data in an outgoing response to a GetUser or ListUsers request, call the AddUserEmail, AddUserPhone, and AddUserExtension methods once for each item to be returned.

scim.OnGetUser += (sender, e) => { var user = db.FindUser(e.UserId); e.UserName = user.UserName; e.DisplayName = user.DisplayName; e.Active = user.Active;
foreach (var email in user.Emails) scim.AddUserEmail(e.UserId, email.Address, email.EmailType, email.Primary); foreach (var phone in user.Phones) scim.AddUserPhone(e.UserId, phone.Number, phone.PhoneType, phone.Primary); if (user.HasManager) scim.AddUserExtension(e.UserId, user.Department, user.EmployeeNumber, user.CostCenter, user.Organization, user.Division, user.ManagerId, "", user.ManagerDisplayName);
e.StatusCode = 200; };

Managing Groups

Groups follow the same pattern as users. The CreateGroup, GetGroup, ListGroups, UpdateGroup, and DeleteGroup events all mirror their user counterparts described above, using a GroupId parameter in place of UserId and a DisplayName in place of the various name fields.

Group members are provided through the GroupMembers collection on CreateGroup and UpdateGroup, holding SCIMMember items that expose a MemberId and MemberDisplay field. To include members in an outgoing response, call the AddGroupMember method once for each member. The AddGroup method is used to add each group to a ListGroups event response.

scim.OnGetGroup += (sender, e) => { var group = db.FindGroup(e.GroupId); e.DisplayName = group.DisplayName; e.ExternalId = group.ExternalId; foreach (var member in group.Members) scim.AddGroupMember(e.GroupId, member.UserId, member.DisplayName); e.StatusCode = 200; };
scim.OnListGroups += (sender, e) => { var page = db.QueryGroups(e.FilterAttribute, e.FilterOperator, e.FilterValue, e.StartIndex - 1, e.Count); foreach (var group in page.Results) scim.AddGroup(group.Id, group.DisplayName, group.ExternalId); e.TotalResults = page.TotalCount; e.StatusCode = 200; };

Discovery Endpoints

The component automatically answers the standard SCIM discovery endpoints so an Identity Provider can inspect your service provider's capabilities before provisioning begins.

A GET /ServiceProviderConfig request fires the GetServiceProviderConfig event, advertising the supported authentication scheme and which operations are enabled, based on the SupportedOperations property. GET /ResourceTypes and GET /Schemas requests fire the GetResourceTypes and GetSchemas events, describing the User and Group resource and schema definitions, including the enterprise user extension.

All three events are informational, and the ResponseBody parameter has already been populated with the serialized JSON by the time each one fires. These events may be used to log or audit discovery requests.

Status Codes and Error Handling

Every resource event includes a StatusCode parameter that the handler must set to report the outcome, defaulting to 500 if left unset (a value of 0 is always treated as the operation's success code). The status codes most commonly returned are summarized below.

Code Meaning
200 The request succeeded and a resource representation is included in the response (GetUser, ListUsers, and successful updates that return the resource).
201 A resource was created successfully (CreateUser, CreateGroup).
204 The request succeeded with no response body (successful deletes, or updates that omit the resource).
400 The request could not be parsed, or a PATCH operation or path is not supported.
404 The requested user or group does not exist.
409 The resource conflicts with an existing one (e.g., a duplicate userName).
412 An If-Match precondition failed (see Conditional Requests with ETags).
501 The requested operation is disabled in the SupportedOperations property.

Any unhandled exception raised while parsing the request or, more generally, if authentication or routing fails before a resource event can even fire, results in the Error event being fired with an ErrorCode and Description, and a corresponding error response is returned to the Identity Provider automatically.

Optional Features

Conditional Requests with ETags

When the ETagEnabled configuration setting is enabled, the component generates an ETag header by hashing the response body and includes it in 200 and 201 responses for individual user and group resources. It then validates an If-Match request header on PUT, PATCH, and DELETE requests; a missing header does not prevent the operation, but a non-matching value returns 412 Precondition Failed before the update or delete event fires. On a GET /Users/{id} or GET /Groups/{id} request, an incoming If-None-Match header is likewise compared against the current resource's ETag, and a 304 Not Modified response is returned with no body when it matches.

This feature is disabled by default and should be enabled only if the connecting Identity Provider requires versioned resources.

Registering Your Service Provider with an Identity Provider

Once the service provider is deployed and reachable, it must be registered with the Identity Provider that will be provisioning users into it. While the exact steps vary by vendor, most Identity Providers (including Microsoft Entra ID, Okta, and OneLogin) require the same two pieces of information when adding a custom SCIM application to their provisioning gallery.

  • Tenant URL / SCIM Base URL - The full public URL of the SCIM endpoint, i.e., the application's host combined with the BaseURL property (e.g., https://example.com/scim/v2).
  • Secret Token - The bearer token configured through the AuthToken property or validated in the AuthRequest event.

After these values are entered, the Identity Provider typically performs a "Test Connection" step, which issues a GET /ServiceProviderConfig request (and often a filtered GET /Users request) to confirm the endpoint is reachable and authenticates successfully, before an administrator assigns users or groups to begin provisioning. Some Identity Providers also allow customizing which SCIM attributes are mapped from their own user schema; the attributes exposed by the CreateUser and UpdateUser events above (including the enterprise extension fields) reflect the full set the component understands.

SCIMDesktop Example

The SCIMDesktop component follows the same event-driven API as SCIMWeb, but hosts its own embedded HTTP server rather than being embedded within an existing web application. The LocalHost and LocalPort properties control the address and port the server listens on, and the StartListening method must be called to begin actively listening for incoming connections.

Additionally, the MaxConnections and IdleTimeout properties may be used to control how many simultaneous connections are accepted and how long an idle connection is kept open, respectively. To expose the endpoint over HTTPS, the SSLEnabled property may be enabled alongside a certificate specified via the SSLCert property before calling StartListening.

scim = new SCIMDesktop();
scim.LocalHost = "127.0.0.1"; scim.LocalPort = 8443; scim.SSLCert = new Certificate(CertStoreTypes.cstPFXFile, "cert.pfx", "password", "*"); scim.SSLEnabled = true;
scim.BaseURL = "/scim/v2"; scim.AuthToken = "the-token-issued-to-your-identity-provider";
scim.OnCreateUser += (sender, e) => { var user = db.CreateUser(e.UserName, e.GivenName, e.FamilyName, e.DisplayName, e.Active, e.ExternalId); e.UserId = user.Id; e.StatusCode = 201; }; // Additional event handlers omitted for brevity; see the sections above.
scim.StartListening();

Because there is no external web server, there is no ProcessRequest method. RequestId is instead correlated across events using the same value described earlier, and each accepted TCP connection is identified by a ConnectionId parameter, first provided by the Connected event.

If connections should be restricted at the network level in addition to the AuthRequest event, the AllowedClients and BlockedClients configuration settings accept a comma-separated list of IP addresses or hostnames, with wildcard support (e.g., 192.168.1.*).

We appreciate your feedback. If you have any questions, comments, or suggestions about this article please contact our support team at support@nsoftware.com.