Start with Identity
Recipe · Advanced · 40 min

Set up SCIM provisioning

Build a SCIM 2.0 server so identity providers can create, update, and deprovision users in your app automatically, with the minimum endpoints that satisfy real connectors.

SCIMProvisioningAPI
Before you start
  • An app with a user store you control
  • A bearer token scheme to authenticate the IdP
  • Familiarity with REST and JSON
Implements

SCIM (System for Cross-domain Identity Management, RFC 7643 and RFC 7644) is the standard that lets an identity provider push user lifecycle changes into your application: create a user on hire, update attributes on a role change, and deactivate on offboarding. Implementing a SCIM server is what makes your app show up as provisionable in directories like Okta, Microsoft Entra ID, and others. For the protocol details, see SCIM 2.0.

The contract

A minimal but real SCIM 2.0 server for the Users resource implements:

GET    /scim/v2/Users           list and filter users
POST   /scim/v2/Users           create a user
GET    /scim/v2/Users/{id}      read one user
PUT    /scim/v2/Users/{id}      replace a user
PATCH  /scim/v2/Users/{id}      partial update (used heavily for activate/deactivate)
DELETE /scim/v2/Users/{id}      delete (some IdPs deactivate via PATCH instead)

Authenticate every request with a bearer token the IdP is configured with. Set Content-Type: application/scim+json.

1. The User resource shape

SCIM users follow a fixed schema. The fields connectors rely on most are userName, active, emails, and name.

{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
  "id": "2819c223-7f76-453a-919d-413861904646",
  "userName": "[email protected]",
  "name": { "givenName": "Jane", "familyName": "Doe" },
  "emails": [{ "value": "[email protected]", "primary": true }],
  "active": true,
  "meta": {
    "resourceType": "User",
    "created": "2026-01-10T10:00:00Z",
    "lastModified": "2026-01-10T10:00:00Z",
    "location": "https://api.example.com/scim/v2/Users/2819c223-7f76-453a-919d-413861904646"
  }
}

2. Create

app.post('/scim/v2/Users', auth, async (req, res) => {
  const { userName, name, emails, active = true } = req.body;
  const existing = await db.findByUserName(userName);
  if (existing) {
    return res.status(409).json({
      schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'],
      detail: 'User already exists',
      status: '409',
    });
  }
  const user = await db.create({ userName, name, emails, active });
  res.status(201).json(toScim(user));
});

3. PATCH: the operation that matters most

Identity providers deactivate users with a PATCH that sets active to false rather than deleting them. Handle the PatchOp format (RFC 7644 section 3.5.2).

app.patch('/scim/v2/Users/:id', auth, async (req, res) => {
  const ops = req.body.Operations ?? [];
  const user = await db.get(req.params.id);
  if (!user) return notFound(res);

  for (const op of ops) {
    if (op.op?.toLowerCase() === 'replace') {
      // op.path may be "active", or op.value may be an object of attrs
      if (op.path === 'active') user.active = op.value === true || op.value === 'True';
      else Object.assign(user, op.value);
    }
  }
  await db.save(user);
  res.status(200).json(toScim(user));
});

When active becomes false, terminate the user's sessions in your app. Deprovisioning that leaves live sessions open defeats the purpose.

4. List and filter

IdPs check for existing users with a filter on userName before creating. You must support at least the eq operator.

// GET /scim/v2/Users?filter=userName eq "[email protected]"&startIndex=1&count=100
app.get('/scim/v2/Users', auth, async (req, res) => {
  const filter = String(req.query.filter ?? '');
  const m = filter.match(/userName eq "(.+?)"/i);
  const users = m ? await db.findByUserName(m[1]).then((u) => (u ? [u] : []))
                  : await db.list();
  res.json({
    schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
    totalResults: users.length,
    startIndex: Number(req.query.startIndex ?? 1),
    itemsPerPage: users.length,
    Resources: users.map(toScim),
  });
});

5. Errors in SCIM format

Every error must use the SCIM error schema, or connectors report opaque failures.

{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
  "detail": "Resource 123 not found",
  "status": "404"
}

Interoperability notes

  • Test against the actual connector you target. Okta, Entra ID, and OneLogin each exercise slightly different subsets and PATCH shapes.
  • Support both DELETE and PATCH-to-inactive: some IdPs deprovision one way, some the other.
  • Return a stable id in meta.location. The IdP stores it and uses it for all later updates.
  • Groups (/Groups) are a second resource type. Add it once Users is solid, if you need group-based provisioning.

Reference: SCIM 2.0, RFC 7643 (schema) and RFC 7644 (protocol).

Free to read and use. Code is illustrative, vendor-neutral, and built on the open standards. Review it against your provider's docs and your threat model before shipping. See more in the recipes index and the standards library.