Ever had to deal with automated user provisioning?
If you’ve ever integrated an enterprise identity provider (like Okta or Azure AD), chances are you’ve bumped into SCIM 2.0. It’s one of those specs that sounds simple—“standardized user provisioning”—until you dig in and realize...it’s a protocol prescribing with its own schema, rules, and quirks.
At Scalekit, we were testing out what it’s like to build a production-ready SCIM 2.0 endpoint, and I want to share what I learned—so you don’t spend hours banging your head against a hard surface :-D
Let’s roll...
First things first: What is SCIM?
SCIM (System for Cross-domain Identity Management) is a standard designed to make user provisioning and deprovisioning across services easier for enterprises. It prescribes a implementation for the your app and the enterprise to simplify the provisioning problem.
Instead of IT teams manually creating and deleting user accounts in each and every app their organization maybe using, SCIM prescribes identity providers (IdPs) like Okta automatically manage users and groups inform your app.
Imagine your app receives a POST /Users
with user info whenever someone joins a company. Or a DELETE /Users/{id}
when someone leaves. That’s SCIM magic right there.
What your endpoint actually needs to do
Here’s what you’re on the hook for if you want to say “yep, we support SCIM”:
- Authentication (usually HTTP Bearer tokens)
- CRUD support for
/Users
and/Groups
endpoints - JSON payloads that conform to RFC7643 and RFC7644
- Pagination, filtering, patching... all the fun stuff
Here’s a simple example of a SCIM-compliant user creation payload:
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "jdoe@example.com",
"name": {
"givenName": "Jane",
"familyName": "Doe"
},
"emails": [
{
"value": "jdoe@example.com",
"primary": true
}
]
}
And the POST /Users
endpoint needs to parse that, validate it, and return a compliant response like:
{
"id": "abcd-1234",
"userName": "jdoe@example.com",
"active": true,
...
}
How I approached building it
When I started implementing SCIM 2.0, I assumed I just needed to slap on a couple REST endpoints. Turns out, you need more than that to make the likes of Okta and Azure AD happy 😅
Here’s how I approached it, piece by piece, using Node.js + Express + PostgreSQL.
1. Setting up your SCIM server
I started with a lightweight Express server. This was something light, but gave me full control over routing and headers.
const express = require("express");
const app = express();
app.use(express.json());
app.listen(3000, () => {
console.log("SCIM server running on port 3000");
});
To secure the endpoints, I added basic Bearer token auth (This is usually configured manually on the IdP side):
function authenticate(req, res, next) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (token !== process.env.SCIM_TOKEN) {
return res.status(401).json({ error: "unauthorized" });
}
next();
}
2. Structuring your SCIM user schema
SCIM requires a specific user structure. The fields you care about (userName, emails, names, etc.) are standardized—but you also need to return everything with the correct SCIM schema metadata.
For storage, I just used a simple PostgreSQL table:
CREATE TABLE scim_users (
id UUID PRIMARY KEY,
username TEXT,
email TEXT,
given_name TEXT,
family_name TEXT,
active BOOLEAN DEFAULT TRUE
);
Insert into command
To translate SCIM requests into this format, I wrote a mapping function:
function scimToUser(payload) {
return {
username: payload.userName,
email: payload.emails?.[0]?.value,
given_name: payload.name?.givenName,
family_name: payload.name?.familyName,
active: payload.active ?? true
};
}
And for responses:
function userToSCIM(user) {
return {
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
id: user.id,
userName: user.username,
name: {
givenName: user.given_name,
familyName: user.family_name
},
emails: [{ value: user.email, primary: true }],
active: user.active,
meta: {
resourceType: "User",
created: new Date().toISOString(),
lastModified: new Date().toISOString()
}
};
}
3. Implementing get, post, delete: the CRUD foundation
POST /Users
→ create a user
app.post("/scim/v2/Users", authenticate, async (req, res) => {
const user = scimToUser(req.body);
const created = await db.users.create(user);
res.status(201).json(userToSCIM(created));
});
GET /Users/:id
→ fetch a user
app.get("/scim/v2/Users/:id", authenticate, async (req, res) => {
const user = await db.users.get(req.params.id);
if (!user) return res.status(404).send();
res.json(userToSCIM(user));
});
DELETE /Users/:id
→ deprovision a user
Instead of hard-deleting, I marked them inactive:
app.delete("/scim/v2/Users/:id", authenticate, async (req, res) => {
await db.users.deactivate(req.params.id);
res.status(204).send();
});
This lets you preserve user history without removing them from your system.
4. Patch: the real challenge
PATCH is where most people give up. SCIM uses its own “PatchOp” format—not JSON Merge Patch
.
You’ll get something like:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "replace",
"path": "active",
"value": false}
]
}
You need to loop through Operations[]
, extract the path
, and manually mutate the user record. Here’s a simplified version:
function applyPatch(user, operations) {
operations.forEach(op => {
if (op.op === "replace" && op.path === "active") {
user.active = op.value;
}
});
return user;
}
This gets complicated when the path includes nested values or array filters, like:
"path": "emails[type eq \"work\"].value"
I ended up writing a recursive patch parser—because SCIM PATCH doesn’t follow JSON standards and can’t be handled by typical libs.
5. Handling edge cases
A few things that tripped me up:
-
Missing
schemas
field → some IdPs reject your payloads silently if this is missing. -
Invalid
meta.created
format → must be ISO8601. -
Expecting
totalResults
, even for one user → always return full list metadata.
For error responses, I returned something like this (per spec):
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "400",
"scimType": "invalidSyntax",
"detail": "Missing required field: userName"
}
6. Filtering, sorting, and pagination logic
SCIM supports filtering like:
GET /Users?filter=userName eq "jdoe@example.com"
So I had to parse this into SQL-safe queries. I built a mini-parser to handle:
filter=fieldName eq "value"
-
startIndex
andcount
- Sorting (though I rarely needed to use this)
Here’s a simplified pagination formatter:
function scimListResponse(results, total, start = 1, count = 10) {
return {
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
totalResults: total,
startIndex: start,
itemsPerPage: results.length,
Resources: results.map(userToSCIM)
};
}
Most IdPs paginate by default, so if you skip this, you risk breaking the sync.
But the simplicity stops there. Because once the basics are done, you’re now on the hook for:
- IdP-specific schema quirks (e.g. Azure AD expects enterprise extension schemas).
- PATCH operations that behave nothing like normal HTTP.
- Filters and pagination you need to support in every list response.
-
Detailed error formats, down to specific SCIM error codes like
409
for conflicts. -
Versioning headers, including ETags (
If-Match
,If-None-Match
) for update validation. -
Custom extension schemas that vendors sometimes sneak in under
urn:ietf:params:scim:schemas:extension
.
The tricky parts (and how I solved them)
1. Schemas confusion
Make sure your responses always include the correct schemas
field, even if it's just:
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"]
If you forget this? Your IdP will return a cryptic 500 or worse — just silently fail.
2. PATCH is weird
This is not your average HTTP PATCH.
You need to parse this:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "replace",
"path": "emails[type eq \"work\"].value",
"value": "new.email@example.com"
}
]
}
Looks reasonable, but:
- You need to write a custom parser to walk the
Operations
array. - You need to support
add
,replace
, andremove
. - If
path
uses dot notation, you need to resolve and modify nested fields correctly.
Most frameworks won’t help here—you’ll end up parsing JSON trees manually. Don’t ask me how I know 😅
3. Pagination and filters
A proper SCIM GET /Users
response needs to support:
startIndex
count
totalResults
Resources[]
Even if you return two users, your SCIM API should still include:
{
"totalResults": 2,
"startIndex": 1,
"itemsPerPage": 2,
"Resources": [...]
}
And allow filters like:
GET /Users?filter=userName eq "jdoe@example.com"
I used SQLAlchemy-style query builders to handle filtering and pagination, but you’ll need to normalize fields, types, and SCIM-specific logic (e.g., case-insensitive string comparisons).
IdPs like Okta and Azure expect these fields or they’ll throw errors—or just fail without explanation.
By the end, I was neck-deep in spec docs
SCIM is hard—and everything needs to be just right for Okta or Azure to say “Provisioning successful.”
I haven’t even gotten into:
- PATCHing
multiValued
fields - Custom
schemas
extensions -
Returning SCIM-specific error messages like:
{ "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "status": "409", "scimType": "uniqueness", "detail": "Email already in use." }
So yeah—three endpoints turns into three weeks really fast.
Tips from the field
- Use Postman to replay IdP requests with test data.
- Keep logs verbose while integrating (you’ll thank yourself later).
- Okta’s SCIM validator tool is your best friend.
Bonus: Skip the process with Scalekit
If this sounds like a lot, it's because it is. That’s actually why we built Scalekit—to abstract away SCIM, SAML, and all that enterprise plumbing. You drop in a few lines of code, and boom: SCIM endpoint live, tested, and validated.
(But hey, I still think it’s good to know how the cake is made 😉)
Your turn!
Have you built or integrated SCIM before? How was your experience?
- What frameworks or tools did you use?
- Did you run into a vendor-specific SCIM weirdness (👀 looking at you, Azure AD)?
- Would you find a minimalist open-source SCIM template repo helpful?
Drop your thoughts or horror stories below! Let’s make SCIM less scary—together.
Top comments (0)