Blog

How SCIM provisioning works - tutorial with API calls

SCIM is a widely used protocol, but not many people understand it. This straightforward and comprehensive guide steps through how it works, using real-world examples and API calls and responses.


How SCIM provisioning works - tutorial with API calls

Your startup is growing like crazy, adding new team members weekly and using an Identity Provider (IdP) to manage user access. Without SCIM, your IT team must manually create accounts for every new hire. That's time-consuming and error-prone.

System for Cross-domain Identity Management (SCIM) automates adding users to the set of applications your company uses so that new hires can be productive immediately.

In this tutorial, we'll step through the full SCIM lifecycle, showing API calls and responses between an Identity Provider (IdP) and a Service Provider. You will learn:

  • How SCIM leverages technologies you already know, such as REST endpoints, and token-based authentication
  • User CRUD with SCIM: what it looks like to create (provision), update, or delete (deprovision) users
  • SCIM protocol fundamentals: users, groups, synchronization, and service provider configurations

A practical SCIM example: provisioning new users in GitHub

When your startup hires a new engineer and adds them to the company IdP, they should also get a fresh GitHub account. Next, they’ll need to be added to the right GitHub team so they can see the right codebases and start collaborating with the rest of engineering.

Let’s step through how this works end to end:

  1. Authentication: How the Service Provider (GitHub) knows to trust requests from your IdP
  2. Provisioning: Adding a new user
  3. Modifying: Users to add them to the correct team
  4. Synchronization: Checking for out-of-band changes to the user
  5. Deprovisioning: Removing the user when they leave your company
  6. Handling errors and rate limits: Making your SCIM calls robust to failure
  7. Service Provider Configuration: How your code can figure out what features and limits a given Service Provider supports

1. Authentication: How does the service provider know to trust your IdP’s requests?

What prevents someone outside your company from making a call to GitHub’s SCIM endpoint to provision a user for themselves, which would be a serious security issue?

SCIM endpoints use OAuth 2.0 Bearer tokens for authentication. While JSON Web Tokens (JWTs) are commonly used as Bearer tokens, other token formats are also supported by the OAuth 2.0 specification. The token is supplied in the Authorization header of the HTTP request when calling a Service Provider like GitHub.

The Service Provider validates tokens by verifying their digital signatures using the IdP's public keys (JWKs - JSON Web Key Sets). This cryptographic validation ensures that only tokens issued by your authorized IdP are accepted.

A Service Provider verifies your IdP's authentication tokens by requesting the IdP's public keys (JWKs) and using them to verify the token's signature

Here's an example of making a call to create a new user, passing the IdP-issued token in the Authorization header:

    
const createScimUser = async () => {
    const scimToken = process.env.SCIM_TOKEN
    if (!scimToken) {
        throw new Error('SCIM_TOKEN environment variable not set')
    }
    
    const url = 'https://api.service-provider.com/scim/v2/Users'
    
    const options = {
        method: 'POST',
        headers: {
            'Authorization': `Bearer ${scimToken}`,
            'Content-Type': 'application/scim+json',
            'Accept': 'application/scim+json'
        },
        body: JSON.stringify({
            schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
            userName: "john.doe@example.com",
            name: {
                givenName: "John",
                familyName: "Doe"
            },
            active: true
        })
    }

    try {
        const response = await fetch(url, options)
        if (!response.ok) {
            throw new Error(`SCIM request failed: ${response.status} ${response.statusText}`)
        }
        return response.json()
    } catch (error) {
        console.error('Error provisioning user:', error)
        throw error
    }
}
    

The sample code above calls your service provider’s SCIM API’s Users endpoint to provision a new user:

`https://api.service-provider.com/scim/v2/Users`

SCIM provides standardized REST endpoints for operations on users and groups:

  • /Users : Manages user resources (create, read, update, delete users)
  • /Groups : Manages team memberships
  • /ServiceProviderConfig : Exposes supported features and configurations

Now that we’ve got the basics of authentication and endpoints down, let’s see both in action as we provision users for your newest team member, John Doe.

2. Provisioning: creating a new user in your application automatically

When you assign an application to a new hire “john.doe@example.com" in your IdP, this API call is automatically made:


POST https://api.service-provider.com/scim/v2/Users
Authorization: Bearer 
Content-Type: application/scim+json

{
    "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
    "userName": "john.doe@example.com",
    "name": {
        "givenName": "John",
        "familyName": "Doe"
    },
    "active": true,
    "externalId": "user-123"
}

Note the externalId field in our request - this is a crucial SCIM concept that helps correlate identities between systems.

Your IdP typically sets this to its internal user identifier, allowing it to maintain a stable reference to the user even if other attributes like email change. Think of it as a foreign key linking the user's identity across your systems.

In response to this call, the Service Provider confirms the user creation:


HTTP/1.1 201 Created
Content-Type: application/scim+json

{
    "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
    "id": "23462",
    "userName": "john.doe@example.com",
    "name": {
        "givenName": "John",
        "familyName": "Doe"
    },
    "active": true,
    "externalId": "user-123",
    "meta": {
        "resourceType": "User",
        "created": "2024-10-22T15:30:00.000Z",
        "location": "https://api.service-provider.com/scim/v2/Users/23462"
    }
}

Now John Doe has a new GitHub account, but we still need to assign them to the correct team. Note that the service provider returns the internal ID it uses to represent this user.

3. Modification: updating the user to add them to the correct team

Group membership can be managed either through PATCH operations on the user or directly via the /Groups endpoint, depending on your service provider's implementation. Here's an example using PATCH:


PATCH https://api.service-provider.com/scim/v2/Users/23462
Authorization: Bearer 
Content-Type: application/scim+json

{
    "schemas": ["urn:ietf:params:scim:schemas:api:messages:2.0:PatchOp"],
    "Operations": [
        {
            "op": "add",
            "path": "groups",
            "value": [{
                "value": "45678",
                "display": "Engineering"
            }]
        }
    ]
}

The Service Provider confirms the team assignment:


HTTP/1.1 200 OK
Content-Type: application/scim+json

{
    "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
    "id": "23462",
    "userName": "john.doe@example.com",
    "active": true,
    "groups": [
        {
            "value": "45678",
            "display": "Engineering"
        }
    ]
}

At this point, John Doe has been successfully provisioned and added to the correct team, so he can get to work!

4. Synchronization: checking for out-of-band changes

What if you make changes to a user that was provisioned via SCIM, perhaps by updating your GitHub account settings? How does your IdP keep everything in sync?

Your IdP needs to track both creation and modification timestamps to maintain proper synchronization. Here's how it might query for updates:


GET https://api.service-provider.com/scim/v2/Users?filter=meta.lastModified gt "2024-10-21T00:00:00Z"
Authorization: Bearer 

For initial synchronization, you might also need to query based on creation date:


GET https://api.service-provider.com/scim/v2/Users?filter=meta.created gt "2024-10-21T00:00:00Z"
Authorization: Bearer 

Webhooks are another common solution to synchronization challenges - they allow your Service Providers to send critical events like user updates to endpoints you control so that you can update records within your IdP and other systems.

Like everything in engineering, this approach comes with tradeoffs - a potentially more scalable solution is to use an events API to be notified of critical SCIM updates.

5. Deprovisioning: removing a user when they leave the company

It turns out that John Doe doesn’t value code quality or security.

When an employee leaves, the deprovisioning behavior varies by service provider. Some providers deactivate accounts, while others support permanent deletion.

Here's an example of deactivation:


PATCH https://api.service-provider.com/scim/v2/Users/23462
Authorization: Bearer 
Content-Type: application/scim+json

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

For providers that support deletion, the call may look something like this:


DELETE https://api.service-provider.com/scim/v2/Users/23462
Authorization: Bearer 

That’s the full SCIM lifecycle for a user completed, but what other concerns must we address when building a robust and performant SCIM implementation?

Most of the same issues that affect any other REST API: handling failures, timeouts, rate limits and preventing duplicate entities. We’ll examine how to tackle these next.

6. Handling errors, rate limits and idempotency: making your SCIM calls robust to failure

SCIM operations can fail for various reasons, including:

  • Network connectivity issues
  • Invalid tokens, expired tokens or authentication failures (HTTP 401)
  • Rate limiting (HTTP 429)
  • Server errors (HTTP 5xx)

Here's an example of implementing exponential backoff for handling rate limits:


async function makeScimRequest(url, options, maxRetries = 3) {
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
        try {
            const response = await fetch(url, options);
            
            if (response.ok) {
                return response.json();
            }
            
            if (response.status === 429) {
                // Calculate wait time with exponential backoff
                const waitMs = Math.min(1000 * Math.pow(2, attempt), 10000);
                console.log(`Rate limited. Waiting ${waitMs}ms before retry...`);
                await new Promise(resolve => setTimeout(resolve, waitMs));
                continue;
            }
            
            throw new Error(`SCIM request failed: ${response.status}`);
        } catch (error) {
            if (attempt === maxRetries) {
                throw error;
            }
        }
    }
}

Timeouts and rate limits are one problem, but what if you’re making a lot of requests very quickly - how can you avoid duplicate account creation issues?

One approach is using idempotency tokens which are unique to every request. It’s common to use a UUID library or function to implement this:


const options = {
    headers: {
        'Idempotency-Key': generateUniqueKey(), // Implementation depends on your needs
        // ... other headers
    }
}

Another approach to preventing conflicting updates is using ETags and conditional headers:


// First, get the current user data and its ETag
const getUserWithETag = async (userId) => {
    const response = await fetch(`https://api.service-provider.com/scim/v2/Users/${userId}`, {
        headers: {
            'Authorization': `Bearer ${scimToken}`
        }
    });
    const etag = response.headers.get('ETag');
    return { user: await response.json(), etag };
};

// Then use If-Match when updating to ensure no concurrent modifications
const updateUser = async (userId, updates, etag) => {
    const response = await fetch(`https://api.service-provider.com/scim/v2/Users/${userId}`, {
        method: 'PATCH',
        headers: {
            'Authorization': `Bearer ${scimToken}`,
            'If-Match': etag,
            'Content-Type': 'application/scim+json'
        },
        body: JSON.stringify(updates)
    });
    
    if (response.status === 412) {
        // Precondition Failed - someone else modified the user
        // Handle accordingly (e.g., retry with fresh ETag)
    }
};

Robust SCIM implementations should include:

  • Retry logic with exponential backoff
  • Administrator notifications for persistent failures
  • Logging for audit trails and troubleshooting
  • Handling of concurrent provisioning requests, ensuring no duplicate entities

Another challenge of implementing SCIM properly is managing the many different Service Providers’ quirks and capabilities. One application may allow bulk operations, another may not. One application might support filtering up to 100 results at once, another may have a much higher limit.

Aside from documentation, how can we ensure we’re building against the true limits and functionality of a given Service Provider? The third standard SCIM endpoint, `/ServiceProviderConfig`, exposes dynamic information specific to a given provider.

6. Service provider configuration: dynamically determine provider capabilities

The ServiceProviderConfig endpoint tells you what SCIM features a service supports. Here's an example response. Take a look and consider what kind of things is this information useful for:


GET https://api.service-provider.com/scim/v2/ServiceProviderConfig
Authorization: Bearer 

Response:
{
    "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
    "patch": {
        "supported": true
    },
    "bulk": {
        "supported": true,
        "maxOperations": 1000
    },
    "filter": {
        "supported": true,
        "maxResults": 100
    },
    "changePassword": {
        "supported": false
    },
    "sort": {
        "supported": false
    },
    "etag": {
        "supported": false
    },
    "authenticationSchemes": [
        {
            "type": "oauthbearertoken",
            "name": "OAuth Bearer Token",
            "description": "Authentication scheme using the OAuth Bearer Token Standard",
            "specUri": "http://www.rfc-editor.org/info/rfc6750"
        }
    ]
}

The ServiceProviderConfig endpoint helps during development and runtime. You can verify support features during implementation.

You can also use this endpoint to enable or disable functionality based on service provider capabilities or adapt to hard limits, such as the number of bulk operations the target application will perform at once.

Since these Service Provider configuration responses rarely change, you can implement caching to reduce the number of total API calls you need to make.

Ready to add SCIM to your application?

We've demonstrated the basic SCIM flow end to end here, but implementing SCIM in your production application requires building and maintaining complex provisioning logic, user synchronization, and enterprise-grade security features.

WorkOS already built these components so that you can make your app Enterprise Ready in minutes, not months. Our SCIM implementation provides:

  • Automatic retry handling
  • Support for all major SCIM versions (1.1, 2.0)
  • Built-in compatibility with all major identity providers
  • Provider-specific mappings and transformations
  • Comprehensive audit logging
  • Enterprise-grade security and compliance

When enterprises ask if your app supports automated user provisioning, you can say yes—without spending months building SCIM yourself. Try WorkOS today, and start selling to enterprise customers tomorrow.

In this article

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.