Backend / Authentication / SSO / 06_scim_provisioning.md

SCIM and User Provisioning

Updated 6 interview angles 7 min read source
On this page13
  1. JIT — the quick win
  2. SCIM — System for Cross-domain Identity Management
  3. Core SCIM endpoints
  4. ServiceProviderConfig — declare what you support
  5. Auth — Bearer token, usually
  6. SCIM error responses
  7. SCIM Groups and group sync
  8. SCIM vs JIT — which?
  9. SCIM implementation reality
  10. Real-world SCIM workflow
  11. Common pitfalls
  12. Common interview confusions
  13. Interview angle

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:

  1. Looks up the user by external ID (sub for OIDC, NameID for SAML, or email).
  2. If not found: create the user with attributes from the assertion.
  3. If found: optionally update attributes from the assertion.
python
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:

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

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

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

text
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

http
GET /scim/v2/ServiceProviderConfig HTTP/1.1

Response:

json
{
  "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).
python
@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:

json
{
  "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:

http
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.

http
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 replace for everything; some use add/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:

  1. Customer admin creates an “App” in their IdP (Okta).
  2. They turn on “SCIM provisioning” and paste your SCIM endpoint URL + a bearer token (generated in your admin UI).
  3. The IdP sends a test request (GET /ServiceProviderConfig).
  4. The IdP imports its users into your app (POST each via SCIM).
  5. The IdP keeps the data in sync — user added to a group at IdP → SCIM PATCH to your app.
  6. 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.
  • userName uniqueness violations — two IdPs syncing the same user with different externalId. Make externalId the primary identifier; treat userName as 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 6

  • “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, /ResourceTypes with 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).