Implement SCIM 2.0 user provisioning endpoints in your app
For developers adding SCIM 2.0 provisioning to their own application. This walks you through the exact endpoints, headers, payload handling, and verification commands needed to support user and group provisioning without guesswork.
TL;DR — To support SCIM 2.0 provisioning, expose
/scim/v2/ServiceProviderConfig,/scim/v2/ResourceTypes,/scim/v2/Schemas,/scim/v2/Users, and usually/scim/v2/Groupsover HTTPS, require bearer-token auth, and implement correct filtering, pagination, PATCH, and stableid/externalIdbehavior. The most common failure is returning app-shaped JSON instead of SCIM-shaped JSON, especially for list responses and PATCH semantics. Reading time: ~5 min
Goal
When you are done, your application exposes working SCIM 2.0 HTTPS endpoints that an identity provider can call to create, update, deactivate, reactivate, and optionally group-manage users, and curl verification against those endpoints returns SCIM-compliant JSON and status codes.
Prerequisites
- HTTPS-enabled application base URL, for example
https://app.example.com - Ability to add routes and deploy backend code in your application
- A database table for users, and optionally groups and group memberships
- A long random bearer token you will give to the identity provider, for example generated with
openssl rand -hex 32 curl >= 8— check with:
curl --version
jq >= 1.6— check with:
jq --version
- A way to inspect application logs in production or staging
- SCIM 2.0 core schema familiarity at implementation level:
id,externalId,userName,active,name,emails,meta
Steps
Step 1: Create a dedicated SCIM bearer token
Generate one token and store only its hash in your database or secret store.
openssl rand -hex 32
Example output:
9f6d7d4f0f5f1d8b8a6a5d6f0c0d5b2f6a4b0a1e9c3d7f8a1b2c3d4e5f607182
What you should see when this succeeds: a 64-character hex token you can paste into your app config and later into the IdP SCIM setup.
Step 2: Add SCIM routes under /scim/v2
Create these exact routes in your app:
GET /scim/v2/ServiceProviderConfig
GET /scim/v2/ResourceTypes
GET /scim/v2/Schemas
GET /scim/v2/Users
POST /scim/v2/Users
GET /scim/v2/Users/:id
PUT /scim/v2/Users/:id
PATCH /scim/v2/Users/:id
DELETE /scim/v2/Users/:id
GET /scim/v2/Groups
POST /scim/v2/Groups
GET /scim/v2/Groups/:id
PUT /scim/v2/Groups/:id
PATCH /scim/v2/Groups/:id
DELETE /scim/v2/Groups/:id
If you do not support groups, still expose /ServiceProviderConfig, /ResourceTypes, and /Schemas accurately and omit group operations from advertised capabilities.
What you should see when this succeeds: your router lists the routes, and requests to unknown SCIM paths return 404 while known ones reach your handler.
Step 3: Require bearer auth and return SCIM errors
For every /scim/v2/* route, require this header format:
Authorization: Bearer 9f6d7d4f0f5f1d8b8a6a5d6f0c0d5b2f6a4b0a1e9c3d7f8a1b2c3d4e5f607182
On missing or bad token, return exactly:
HTTP/1.1 401 Unauthorized
Content-Type: application/scim+json
WWW-Authenticate: Bearer realm="scim"
Body:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "401",
"detail": "Bearer token is missing or invalid"
}
What you should see when this succeeds: unauthenticated curl gets 401, authenticated curl reaches the handler.
Step 4: Return metadata endpoints with literal SCIM JSON
Implement these exact response shapes.
GET /scim/v2/ServiceProviderConfig
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
"patch": { "supported": true },
"bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 },
"filter": { "supported": true, "maxResults": 200 },
"changePassword": { "supported": false },
"sort": { "supported": false },
"etag": { "supported": false },
"authenticationSchemes": [
{
"type": "oauthbearertoken",
"name": "Bearer Token",
"description": "Static bearer token for SCIM provisioning",
"specUri": "https://www.rfc-editor.org/rfc/rfc6750"
}
]
}
GET /scim/v2/ResourceTypes
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": 2,
"startIndex": 1,
"itemsPerPage": 2,
"Resources": [
{
"id": "User",
"name": "User",
"endpoint": "/Users",
"schema": "urn:ietf:params:scim:schemas:core:2.0:User"
},
{
"id": "Group",
"name": "Group",
"endpoint": "/Groups",
"schema": "urn:ietf:params:scim:schemas:core:2.0:Group"
}
]
}
What you should see when this succeeds: curl returns 200 with Content-Type: application/scim+json and valid JSON.
Step 5: Implement Users create, lookup, list, and update semantics
Map SCIM fields to your user model with these minimum rules:
| SCIM field | Store in app |
|---|---|
id | immutable internal user ID as string |
externalId | IdP-side identifier if sent |
userName | unique login identifier |
active | active/deactivated boolean |
name.givenName | first name |
name.familyName | last name |
emails[0].value | primary email |
Implement GET /Users?filter=userName eq "alice@example.com" and GET /Users?filter=externalId eq "123".
List response shape must be:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": 1,
"startIndex": 1,
"itemsPerPage": 1,
"Resources": [
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": "42",
"externalId": "00u123abc",
"userName": "alice@example.com",
"active": true,
"name": { "givenName": "Alice", "familyName": "Ng" },
"emails": [{ "value": "alice@example.com", "primary": true }],
"meta": { "resourceType": "User", "location": "https://app.example.com/scim/v2/Users/42" }
}
]
}
For deprovisioning, prefer PATCH or PUT setting active: false; do not hard-delete unless your customer explicitly wants that behavior.
What you should see when this succeeds: creating a user returns 201, fetching by id returns 200, and deactivating returns 200 with active: false.
Step 6: Implement PATCH exactly enough for real IdPs
Support PATCH /Users/:id with this schema:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{ "op": "Replace", "path": "active", "value": false }
]
}
Also support whole-object replace payloads like:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "Replace",
"value": {
"userName": "alice@example.com",
"name": { "givenName": "Alice", "familyName": "Ng" },
"active": true
}
}
]
}
Return 200 with the updated SCIM resource. If you cannot support a requested path, return:
HTTP/1.1 400 Bad Request
Content-Type: application/scim+json
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "400",
"scimType": "invalidPath",
"detail": "Unsupported PATCH path: members[value eq \"123\"]"
}
What you should see when this succeeds: the IdP can deactivate and reactivate users without falling back to full reprovisioning.
Step 7: Add group support if your customers assign access by group
If your app supports team or role mapping, implement Groups with displayName and members.
Minimal group object:
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
"id": "7",
"displayName": "Engineering",
"members": [
{
"value": "42",
"$ref": "https://app.example.com/scim/v2/Users/42",
"display": "alice@example.com"
}
],
"meta": { "resourceType": "Group", "location": "https://app.example.com/scim/v2/Groups/7" }
}
⚠️ If group membership drives authorization in your app, a bad PATCH handler can remove access for many users at once. Test group PATCH in staging against copied production-like data before enabling it for customers.
What you should see when this succeeds: adding or removing a member via SCIM updates your membership table and is reflected on the next GET /Groups/:id.
Verify it works
Run these commands against your deployed app.
Check auth failure:
curl -i https://app.example.com/scim/v2/ServiceProviderConfig
Expected shape:
HTTP/1.1 401 Unauthorized
Content-Type: application/scim+json
WWW-Authenticate: Bearer realm="scim"
Check metadata:
curl -sS -H 'Authorization: Bearer 9f6d7d4f0f5f1d8b8a6a5d6f0c0d5b2f6a4b0a1e9c3d7f8a1b2c3d4e5f607182' https://app.example.com/scim/v2/ServiceProviderConfig | jq .
Create a user:
curl -sS -X POST \
-H 'Authorization: Bearer 9f6d7d4f0f5f1d8b8a6a5d6f0c0d5b2f6a4b0a1e9c3d7f8a1b2c3d4e5f607182' \
-H 'Content-Type: application/scim+json' \
https://app.example.com/scim/v2/Users \
-d '{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"externalId":"00u123abc","userName":"alice@example.com","name":{"givenName":"Alice","familyName":"Ng"},"emails":[{"value":"alice@example.com","primary":true}],"active":true}' | jq .
Filter lookup:
curl -sS -G \
-H 'Authorization: Bearer 9f6d7d4f0f5f1d8b8a6a5d6f0c0d5b2f6a4b0a1e9c3d7f8a1b2c3d4e5f607182' \
--data-urlencode 'filter=userName eq "alice@example.com"' \
https://app.example.com/scim/v2/Users | jq '.totalResults, .Resources[0].id'
Deactivate:
curl -sS -X PATCH \
-H 'Authorization: Bearer 9f6d7d4f0f5f1d8b8a6a5d6f0c0d5b2f6a4b0a1e9c3d7f8a1b2c3d4e5f607182' \
-H 'Content-Type: application/scim+json' \
https://app.example.com/scim/v2/Users/42 \
-d '{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[{"op":"Replace","path":"active","value":false}]}' | jq '.active'
Expected result: false, and your app login for that user is blocked according to your normal inactive-user logic.
Common pitfalls
Returning application/json with app-specific payloads
Mistake: sending your normal REST response shape like { "user": ... }.
Symptom: the IdP says provisioning failed or cannot parse list responses.
Fix: return SCIM resource objects and application/scim+json on every SCIM endpoint.
Not implementing filter lookup on userName
Mistake: only supporting GET /Users/:id.
Symptom: the IdP creates duplicate users because existence checks always return empty.
Fix: implement GET /Users?filter=userName eq "value" and return a SCIM ListResponse.
Hard-deleting on DELETE /Users/:id
Mistake: removing the row immediately.
Symptom: reprovisioning creates a new account with lost history or broken foreign keys.
Fix: map delete/deprovision to active = false unless your product explicitly requires irreversible deletion.
Redirecting /scim/v2 through login or HTML middleware
Mistake: SCIM routes inherit browser auth middleware.
Symptom: curl -I shows a redirect instead of SCIM JSON:
HTTP/1.1 302 Found
Location: /login
Content-Type: text/html; charset=utf-8
Fix: mount SCIM routes before session/login middleware and use bearer-token auth only.
Treating id as mutable or using email as primary key
Mistake: setting SCIM id = userName.
Symptom: email rename breaks updates and group memberships.
Fix: use an immutable internal string ID for id; store email in userName and emails[0].value.
Ignoring PATCH path variants
Mistake: only handling full-resource PUT.
Symptom: deactivation fails when the IdP sends PATCH with path: "active".
Fix: implement PatchOp with at least Replace active, basic profile field replaces, and group member add/remove if you support groups.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI