SCIM Provisioning for Multi-Tenant SaaS

SCIM turns each customer's identity provider into the authority for who exists in their tenant, and this page shows how to accept those pushes from hundreds of directories without ever crossing a tenant boundary. It is a focused part of SSO Mapping & Identity Federation: the endpoint contract, per-tenant credentials, and deprovisioning that actually ends access.

Problem Framing

Single sign-on answers "can this person log in". SCIM answers "does this person exist, and what are they entitled to" — which is what enterprise customers actually need, because SSO alone leaves a departed employee's account present, licensed, and reachable through any non-SSO path until someone notices.

The multi-tenant challenge is that the SCIM endpoint is a single URL receiving pushes from many independent directories, each believing it owns the namespace. Two customers will both have jane@example.com as a guest. Two customers will both push a group called Admins. The provider-assigned identifier your service returns must therefore be unique per tenant, and every lookup must be scoped by tenant and by the external identifier, never by email alone.

The second challenge is idempotency. Identity providers retry aggressively and reorder freely; a PATCH may arrive before the POST that created the user, and the same create may arrive twice. Treating the external identifier as the natural key and making every operation an upsert removes an entire class of duplicate-account incidents.

Step-by-Step Guide

1. Issue one token per tenant, stored hashed

CREATE TABLE scim_credential (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   uuid NOT NULL,
  token_hash  bytea NOT NULL UNIQUE,         -- SHA-256 of the token; the value is shown once
  label       text NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now(),
  last_used_at timestamptz,
  revoked_at  timestamptz
);
CREATE INDEX ON scim_credential (tenant_id) WHERE revoked_at IS NULL;
// auth.ts
export async function tenantFromScimToken(header: string | undefined) {
  const raw = header?.replace(/^Bearer\s+/i, '');
  if (!raw) throw new HttpError(401, 'missing token');
  const hash = sha256(raw);
  const cred = await db.oneOrNone(
    `SELECT tenant_id, id FROM scim_credential WHERE token_hash = $1 AND revoked_at IS NULL`, [hash]);
  if (!cred) throw new HttpError(401, 'invalid token');
  void db.query(`UPDATE scim_credential SET last_used_at = now() WHERE id = $1`, [cred.id]);
  return cred.tenantId;
}

Supporting two live tokens per tenant is what makes rotation possible without a coordinated change window on the customer's side.

2. Make the external identifier the natural key, scoped per tenant

CREATE TABLE tenant_user (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id    uuid NOT NULL,
  external_id  text NOT NULL,                -- the directory's immutable id
  user_name    text NOT NULL,                -- SCIM userName, usually the email
  display_name text,
  active       boolean NOT NULL DEFAULT true,
  UNIQUE (tenant_id, external_id),
  UNIQUE (tenant_id, lower(user_name))       -- unique inside the tenant, never globally
);

Both constraints are scoped by tenant. A global unique constraint on the email is the single most common reason a second customer's SCIM sync fails with a mysterious 409.

3. Implement create and patch as one upsert

// users.ts
export async function upsertUser(tenantId: string, res: ScimUser) {
  const { rows } = await db.query(
    `INSERT INTO tenant_user (tenant_id, external_id, user_name, display_name, active)
     VALUES ($1,$2,$3,$4,$5)
     ON CONFLICT (tenant_id, external_id) DO UPDATE
       SET user_name = EXCLUDED.user_name,
           display_name = EXCLUDED.display_name,
           active = EXCLUDED.active
     RETURNING id, (xmax = 0) AS created`,
    [tenantId, res.externalId, res.userName, res.displayName ?? null, res.active ?? true],
  );
  return { id: rows[0].id, created: rows[0].created };
}

xmax = 0 distinguishes an insert from an update, which is what lets the handler return the correct 201 or 200 — some identity providers treat the wrong status as a hard failure and stop syncing.

4. Treat deactivation as revocation, not as a flag

// deactivate.ts
export async function deactivate(tenantId: string, userId: string) {
  await db.tx(async (t) => {
    await t.query(`UPDATE tenant_user SET active = false WHERE tenant_id = $1 AND id = $2`,
      [tenantId, userId]);
    await t.query(`UPDATE tenant_role_grant SET revoked_at = now()
                   WHERE tenant_id = $1 AND principal_id = $2 AND revoked_at IS NULL`,
      [tenantId, userId]);
    await t.query(`INSERT INTO revocation_outbox (tenant_id, principal_id) VALUES ($1,$2)`,
      [tenantId, userId]);
  });
}

The worker that drains the outbox bumps the authorization epoch and drops cached sessions, exactly as in invalidating tenant sessions on role change. Without it, active = false merely stops future logins while every issued token keeps working.

5. Map groups to roles through an explicit, reviewable table

// groups.ts
export async function syncGroupMembers(tenantId: string, groupExternalId: string, memberIds: string[]) {
  const roleKey = await db.oneOrNone(
    `SELECT role_key FROM idp_group_mapping WHERE tenant_id = $1 AND group_external_id = $2`,
    [tenantId, groupExternalId]);
  if (!roleKey) return { mapped: false };            // unmapped groups grant nothing, silently
  await replaceGrants(tenantId, roleKey.roleKey, memberIds);
  return { mapped: true, count: memberIds.length };
}

An unmapped group granting nothing is the correct default: a customer creating a group called Admins in their directory must not silently obtain admin rights in your product. The mapping is an explicit decision, and it belongs in the review cycle described in Tenant Access Reviews & Certification.

Verification

// scim.spec.ts
it('keeps identical emails in different tenants separate', async () => {
  await scim('acme').post('/Users').send(user('jane@example.com', 'ext-1')).expect(201);
  await scim('globex').post('/Users').send(user('jane@example.com', 'ext-9')).expect(201);
  expect(await countUsers('acme')).toBe(1);
  expect(await countUsers('globex')).toBe(1);
});

it('is idempotent on repeated creates', async () => {
  await scim('acme').post('/Users').send(user('sam@example.com', 'ext-2')).expect(201);
  await scim('acme').post('/Users').send(user('sam@example.com', 'ext-2')).expect(200);
  expect(await countUsers('acme')).toBe(1);
});

it('ends live sessions when a user is deactivated', async () => {
  const session = await loginAs('acme', 'ext-2');
  await scim('acme').patch('/Users/ext-2').send(setActive(false)).expect(200);
  await drainRevocations();
  expect((await api.get('/me').set('Cookie', session)).status).toBe(401);
});
-- Drift: users active in your system but absent from the last full sync.
SELECT u.id, u.user_name
FROM   tenant_user u
LEFT   JOIN scim_sync_seen s ON s.tenant_id = u.tenant_id AND s.external_id = u.external_id
WHERE  u.tenant_id = $1 AND u.active
  AND (s.seen_at IS NULL OR s.seen_at < now() - interval '48 hours');

Failure Modes & Gotchas

FAQ

Should SCIM be available on every plan? It is usually an enterprise-tier feature, and that is defensible — it exists to serve customers with a directory of record. What should not be tier-gated is deprovisioning: if a customer can create users through your product, they must be able to remove them and end their sessions on every plan. Gating the automation is reasonable; gating the security outcome is not.

What happens when a group is unmapped? Nothing, deliberately. Members of an unmapped group get no roles from it, and the response should still succeed so the customer's sync does not fail. Surface unmapped groups in the tenant's admin interface so an administrator can map them intentionally — silent auto-mapping by name is how a customer's internal Admins group becomes product admin without anyone deciding it should.

How do you rotate a SCIM token without breaking the sync? Support two active credentials per tenant. Issue the new token, let the customer update their directory configuration, watch last_used_at move to the new credential, then revoke the old one. Rotating in a single step guarantees a failed sync window, and a failed sync window is when deprovisioning stops happening.

Should SCIM be able to create roles as well as users? No. The role catalogue is the platform's, and letting an inbound provisioning stream define new roles means a customer's directory can invent capabilities your product does not have. What SCIM should do is assert membership: this user exists, these groups contain them, and the tenant's own mapping table translates those groups into roles you already publish. That separation keeps the catalogue reviewable and keeps the failure mode benign — an unmapped group grants nothing rather than something undefined.

How should the endpoint handle a customer's full re-sync? Gracefully and without deprovisioning everything. Providers periodically push a complete picture, and a naive implementation that treats every absent user as a deletion will empty a tenant the first time a partial sync is truncated by a timeout. Treat absence as a signal to reconcile rather than as an instruction to delete: record which principals were seen, compare against the active set, and surface the difference for review before acting on anything at scale. Deactivation driven by an explicit active: false is unambiguous; deactivation inferred from silence is not.

What should the endpoint return for a user the tenant has never seen? A standard not-found response with the SCIM error schema, rather than an empty success. Providers use the status code to decide whether to create or update, so returning success for a missing resource sends them down the update path and produces a stream of failed patches that looks like a broken integration on their side rather than a contract violation on yours.