backend / authentication / sso / 06_scim_provisioning.md

SCIM and User Provisioning

6 interview angles 7 min read source

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

  • “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).