DEV Community

Cover image for Notes on building a SCIM endpoint from scratch
Saif
Saif

Posted on

Notes on building a SCIM endpoint from scratch

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”:

  1. Authentication (usually HTTP Bearer tokens)
  2. CRUD support for /Users and /Groups endpoints
  3. JSON payloads that conform to RFC7643 and RFC7644
  4. 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
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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,
  ...
}
Enter fullscreen mode Exit fullscreen mode

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");
});
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  };
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

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));
});
Enter fullscreen mode Exit fullscreen mode

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));
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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}
  ]
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

This gets complicated when the path includes nested values or array filters, like:

"path": "emails[type eq \"work\"].value"
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

6. Filtering, sorting, and pagination logic

SCIM supports filtering like:

GET /Users?filter=userName eq "jdoe@example.com"
Enter fullscreen mode Exit fullscreen mode

So I had to parse this into SQL-safe queries. I built a mini-parser to handle:

  • filter=fieldName eq "value"
  • startIndex and count
  • 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)
  };
}
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Looks reasonable, but:

  • You need to write a custom parser to walk the Operations array.
  • You need to support add, replace, and remove.
  • 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": [...]
}
Enter fullscreen mode Exit fullscreen mode

And allow filters like:

GET /Users?filter=userName eq "jdoe@example.com"
Enter fullscreen mode Exit fullscreen mode

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)