SCIM and User Provisioning
SSO answers “is this user authentic?” Provisioning answers “should this user exist in my system, and what are their attributes?” Two strategies:
- JIT (Just-In-Time): create/update the user on first login, from the IdP’s assertion.
- SCIM: IdP actively pushes user changes to your app via API.
Most B2B SaaS supports both. Enterprise customers expect SCIM for ironclad provisioning + deprovisioning.
JIT — the quick win
On every successful SSO login, your app:
- Looks up the user by external ID (
subfor OIDC,NameIDfor SAML, or email). - If not found: create the user with attributes from the assertion.
- If found: optionally update attributes from the assertion.
def on_sso_login(claims: dict) -> User:
user = User.query.filter_by(external_id=claims["sub"]).first()
if user is None:
user = User(
external_id=claims["sub"],
email=claims["email"],
name=claims["name"],
org_id=lookup_org_from_issuer(claims["iss"]),
)
session.add(user)
else:
user.email = claims["email"]
user.name = claims["name"]
session.commit()
return user
What JIT gets right:
- No coordination needed: users get accounts the moment they’re allowed at the IdP.
- Self-service: admins add users to the IdP group; first login creates them.
What JIT misses:
- No deprovisioning: removed user at IdP can’t log in (good), but their account in your app still exists, still has data, still appears in lists.
- No proactive updates: name change at IdP doesn’t propagate until next login.
- Group changes are reactive: if a user is removed from a group, your app finds out next time they log in.
- No bulk operations: can’t seed 1000 users before they each log in.
For “internal-only app where everyone uses it”: JIT is enough. For “compliance-sensitive enterprise SaaS”: you’ll be asked for SCIM.
SCIM — System for Cross-domain Identity Management
SCIM 2.0 (RFC 7643, 7644) is a REST + JSON API standard for user provisioning. The IdP is the SCIM client; your app is the service provider.
The IdP pushes:
POST /scim/v2/Users HTTP/1.1
Authorization: Bearer <token>
Content-Type: application/scim+json
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "alice",
"name": {"givenName": "Alice", "familyName": "Smith"},
"emails": [{"value": "alice@example.com", "primary": true}],
"active": true,
"externalId": "248289761001"
}
Returns:
HTTP/1.1 201 Created
Location: /scim/v2/Users/abc-123
{
"id": "abc-123",
"schemas": [...],
"userName": "alice",
...
}
The IdP saves the id and uses it for future updates:
PATCH /scim/v2/Users/abc-123
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{"op": "replace", "path": "active", "value": false}
]
}
PATCH active=false is the deprovisioning signal — IdP tells your app to disable the user. Common implementations:
- Soft-delete (
is_active=false, keep data). - Revoke sessions (force logout).
- Disable but don’t delete (audit / compliance).
For full delete:
DELETE /scim/v2/Users/abc-123
Core SCIM endpoints
| Endpoint | Method | Purpose |
|---|---|---|
/scim/v2/Users |
POST | create user |
/scim/v2/Users |
GET | list/search users |
/scim/v2/Users/{id} |
GET | get user |
/scim/v2/Users/{id} |
PUT / PATCH | update user |
/scim/v2/Users/{id} |
DELETE | delete user |
/scim/v2/Groups |
POST/GET | manage groups |
/scim/v2/Groups/{id} |
PATCH | add/remove members |
/scim/v2/ServiceProviderConfig |
GET | describe what you support |
/scim/v2/Schemas |
GET | describe attribute schemas |
/scim/v2/ResourceTypes |
GET | describe supported resource types |
Filtering uses a query language:
GET /scim/v2/Users?filter=userName eq "alice"
GET /scim/v2/Users?filter=emails.value co "@example.com"
GET /scim/v2/Users?startIndex=1&count=100
Operators: eq, ne, co (contains), sw (starts with), pr (present), gt, ge, lt, le. Plus and, or, not.
ServiceProviderConfig — declare what you support
GET /scim/v2/ServiceProviderConfig HTTP/1.1
Response:
{
"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": [
{"name": "OAuth Bearer Token", "description": "Authentication via OAuth", "type": "oauthbearertoken"}
]
}
This is how the IdP knows what your implementation supports. Be honest — claiming “yes” on something you don’t implement causes errors during sync.
Auth — Bearer token, usually
SCIM endpoints take a bearer token. The IdP authenticates with a long-lived token your customer (the IdP admin) registered with you. Best practice:
- Per-tenant bearer token (each customer has their own).
- Rotate-able (generate new, accept both old and new for a grace period, revoke old).
- Strong (256-bit random, base64).
- Stored hashed in your DB (you don’t need to read it back — only compare).
@app.middleware("http")
async def scim_auth(request: Request, call_next):
if not request.url.path.startswith("/scim/v2"):
return await call_next(request)
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return JSONResponse({"detail": "missing bearer"}, status_code=401)
token = auth[7:]
tenant = lookup_tenant_by_token_hash(hash_token(token))
if not tenant:
return JSONResponse({"detail": "invalid token"}, status_code=401)
request.state.tenant = tenant
return await call_next(request)
SCIM error responses
Standard format:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"detail": "User not found",
"status": "404",
"scimType": "invalidValue" // optional, when applicable
}
| Status | Cause |
|---|---|
| 400 | bad request (malformed filter, missing required attribute) |
| 401 | missing/invalid auth |
| 403 | not allowed for this user/role |
| 404 | resource not found |
| 409 | conflict (uniqueness violation — duplicate userName) |
| 500 | server error |
scimType values: invalidFilter, invalidPath, invalidValue, mutability, uniqueness, etc.
SCIM Groups and group sync
Groups in SCIM let the IdP push group memberships:
POST /scim/v2/Groups
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
"displayName": "engineers",
"members": [
{"value": "abc-123", "display": "alice"},
{"value": "def-456", "display": "bob"}
]
}
Use case: customer’s IdP has an “engineers” group; your app maps it to a role. As people join/leave the group at the IdP, SCIM PATCH operations sync the changes.
PATCH /scim/v2/Groups/group-id
{
"Operations": [
{"op": "add", "path": "members", "value": [{"value": "ghi-789"}]}
]
}
SCIM vs JIT — which?
| JIT | SCIM | |
|---|---|---|
| Setup complexity | low | medium-high |
| Standard | non-standardized | RFC 7643/7644 |
| Deprovisioning | no — orphaned accounts | yes — IdP pushes deactivation |
| Group sync | reactive (on next login) | proactive (when group changes) |
| Pre-provisioning | no (must log in first) | yes |
| Enterprise expectation | minimum | required for SOC 2 / SCIM-required RFPs |
For B2B SaaS: SCIM is the difference between “we can integrate” and “we have enterprise support.” It’s a sales requirement, not a technical nice-to-have.
SCIM implementation reality
The spec is permissive; IdPs interpret it differently. Common variations:
- Okta, Azure AD, OneLogin, JumpCloud all send slightly different PATCH payloads.
- Some send
replacefor everything; some useadd/remove. - Filter expressions vary in escaping.
Test against the real IdPs your customers use. Don’t trust the spec alone.
Vendor “SCIM-compliant” claims usually mean “compliant enough that this specific IdP works.”
Real-world SCIM workflow
For onboarding a new enterprise customer:
- Customer admin creates an “App” in their IdP (Okta).
- They turn on “SCIM provisioning” and paste your SCIM endpoint URL + a bearer token (generated in your admin UI).
- The IdP sends a test request (
GET /ServiceProviderConfig). - The IdP imports its users into your app (POST each via SCIM).
- The IdP keeps the data in sync — user added to a group at IdP → SCIM PATCH to your app.
- User offboarded at IdP → SCIM PATCH
active=false→ your app deactivates.
The customer never tells you about new hires or departures — the IdP does, via SCIM.
Common pitfalls
- No idempotency — IdP retries a PATCH; your app applies it twice; weird state.
- Hard-deleting on
active=false— lose audit trail, can’t reactivate. Prefer soft-delete. - Not revoking sessions on
active=false— user logged out at IdP but still has a valid session in your app. - Returning data the IdP didn’t send in PATCH responses — confuses some IdPs that round-trip.
userNameuniqueness violations — two IdPs syncing the same user with differentexternalId. MakeexternalIdthe primary identifier; treatuserNameas secondary.- No multi-tenancy — accept SCIM requests but scope all writes/reads to the tenant from the auth token.
Common interview confusions
- “SCIM is for authentication.” — no, it’s for provisioning. SSO (SAML/OIDC) authenticates; SCIM creates/updates/deletes accounts.
- “JIT replaces SCIM.” — JIT covers create + update; SCIM additionally covers deprovisioning, proactive sync, pre-provisioning before first login.
- “SCIM is standard so all IdPs work identically.” — the spec is permissive; vendors interpret differently. Test against the real ones.
Interview angle
- “How do users get into your app from the IdP?” — two options: JIT (create on first SSO login) or SCIM (IdP pushes via API ahead of time). Real enterprise apps support both; SCIM is required for proper deprovisioning.
- “What’s SCIM and why use it over JIT?” — REST/JSON standard (RFC 7643/7644) for the IdP to push user lifecycle events. Beats JIT for: deprovisioning (IdP signals user removal), pre-provisioning, group sync, audit completeness.
- “What happens when a user is offboarded at the IdP?” — with SCIM: IdP PATCHes
active=false, your app deactivates (soft-delete + revoke sessions). With only JIT: nothing immediate; the user can’t log in but their account, data, and active sessions persist. - “What endpoints does SCIM define?” —
/Users,/Groups,/ServiceProviderConfig,/Schemas,/ResourceTypeswith standard REST verbs. Filter via query language (?filter=userName eq "alice"). - “How do you authenticate SCIM requests?” — typically bearer token, one per customer/tenant. Stored hashed, rotatable.
- “What if an IdP sends a PATCH twice?” — must be idempotent. The operation should produce the same final state regardless of how many times it’s applied (deactivating an already-deactivated user is a no-op, not an error).