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.
- An app with a user store you control
- A bearer token scheme to authenticate the IdP
- Familiarity with REST and JSON
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
DELETEand PATCH-to-inactive: some IdPs deprovision one way, some the other. - Return a stable
idinmeta.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).