Custom SCIM schemas: Where identity provisioning meets authorization
What schema extensions are, how Docker and Notion use them, and how to design your own.
SCIM is the standard most enterprise SaaS products use to sync users from an identity provider (IdP) like Okta or Microsoft Entra into the product's own user database. It handles the basics well: provisioning a new hire, deactivating someone who left, keeping names and emails in sync. What it doesn't do out of the box is carry the authorization context an enterprise customer typically wants pushed alongside identity: which team or workspace someone belongs to, and what role they should have there.
That gap is why custom schema extensions exist. This piece covers what they are, what they look like in two real products, and how to design one that you won't end up regretting.
What a SCIM schema extension actually is
SCIM is defined in two IETF RFCs: RFC 7643 (the schema) and RFC 7644 (the protocol). The core User resource gives you userName, emails, name, active, and groups, among others. The standard ships with one extension built in, the enterprise user extension, identified by the URN urn:ietf:params:scim:schemas:extension:enterprise:2.0:User. It adds attributes most B2B products want anyway: employeeNumber, costCenter, organization, division, department, and manager.
A user payload using that extension looks like this:
The schemas array declares which extensions are present, and each extension's attributes are namespaced under its URN as a top-level key. That is the whole pattern. To define your own extension you pick a URN (the convention is urn:ietf:params:scim:schemas:extension:<yourapp>:2.0:User), declare the attributes you want, and IdPs like Okta and Entra give admins a UI to map source attributes into them.
Example 1: Docker
Docker's SCIM implementation uses a custom extension to carry organization, team, and role for each user. The namespace is urn:ietf:params:scim:schemas:extension:docker:2.0:User, and the three documented attributes are dockerOrg, dockerTeam, and dockerRole. Admins configure these in their IdP, and when a user is provisioned, those attributes flow through SCIM so Docker can place the user in the right org and team with the right role.
A representative request body:
Without this extension, the IdP could provision Asha into Docker, but Docker wouldn't know which org she belongs to or what she's allowed to do. With it, the user arrives ready to work.
Example 2: Notion
Notion's extension is narrower, focused on a single attribute: workspace role. The namespace is urn:ietf:params:scim:schemas:extension:notion:2.0:User, and the documented role values are owner, membership_admin, member, and restricted_member.
The contrast is instructive. Docker's authorization model has multiple dimensions (org, team, role), so its extension exposes three attributes. Notion's authorization model at this layer is a single workspace role enum, so the extension is one attribute. The shape of the extension follows the shape of the authorization model. That is the design principle worth taking away.
Designing your own extension
Three things hold up when you build one of these.
- Match your authorization primitives. If your product has workspaces and roles, expose
workspaceIdandrole. Don't invent a generic permissions blob and force IdP admins to format it. Whatever attribute they map, your endpoint should act on it directly. - Pick a stable URN and don't reuse the enterprise namespace. Use
urn:ietf:params:scim:schemas:extension:<yourcompany>:2.0:User. The standard enterprise extension has a defined meaning and some IdPs treat it specially, so cramming custom authorization attributes in there causes subtle problems. - Treat extension attributes as desired state, not commands. The IdP is asserting "this is who they are and what they should have access to." Your endpoint reconciles. If
dockerRolechanges fromeditortoviewer, you downgrade. If it disappears, you decide whether that means revoke or fall back to a default, and you make that decision once, in your spec, not case-by-case in code.
The PATCH lifecycle question
Most SCIM headaches show up not at create time but at update time. RFC 7644 section 3.5.2 defines three PATCH operations: add, remove, and replace. An IdP might send any of them, and your handler has to do the right thing for each.
A role change typically arrives as:
A role removal arrives as:
Two real-world rough edges to plan for.
First, IdPs don't always send spec-compliant PATCH bodies. Microsoft Entra in particular has been observed sending remove operations with a value field used as a filter, even though the spec says the filter belongs in the path. Your parser needs to be defensive about both shapes, or you'll misinterpret a single-member removal as a full revocation.
Second, decide explicitly what a missing attribute means versus an explicit remove. RFC 7644 section 3.5.1 says that when a PUT request omits a writable attribute, the server may either clear it or leave it alone, at the implementer's discretion. Pick one behavior, document it, and stick to it. Customers will write their IdP mappings against whatever you choose.
Doing this with WorkOS
If you don't want to write and maintain a SCIM server yourself, WorkOS Directory Sync covers the same authorization-context use case with a different mental model. Instead of publishing your own URN and parsing inbound SCIM payloads, you define the attributes you care about once in the WorkOS Dashboard, and WorkOS normalizes data from any supported directory provider into a consistent JSON shape on the Directory User object.
Three things make this useful for the patterns Docker and Notion implement above.
- You define custom attributes once, regardless of IdP. Under Configuration → Directory Sync in the WorkOS Dashboard, you declare the attributes your app cares about (e.g.,
workspace_id,tier,cost_center). Your customers' IT admins then map their source fields to those attributes through the WorkOS Admin Portal, using an interactive schema viewer that shows the structure of their directory data. The same logical attribute may live at different nesting depths across Okta, Entra, and Workday; the admin maps it wherever it lives in their directory, and your app reads a flat, normalized value. - Roles get a first-class pattern. For the authorization-context piece specifically, WorkOS supports two approaches. You can define a custom-mapped role attribute (for example,
myRoleresolving toadmin,viewer, oreditor) and have admins map it to whatever field stores roles in their IdP. Alternatively, if customers prefer to manage roles via group membership, you can configure group-to-role assignments in the dashboard, and any user in the "Engineering" group automatically gets the "Developer" role on their directory user response. Multiple-role assignment is supported when a user belongs to several groups. - The result is a normalized API, not raw SCIM. Custom attributes show up on the
custom_attributesfield of the Directory User object returned by the WorkOS API. Updates flow through asdsync.user.updatedevents. If you use AuthKit, those same attributes are available on the organization membership and can be embedded in access tokens via JWT templates, so your app can authorize requests without an extra lookup.
A request to fetch a directory user looks like this:
The trade-off is what you're abstracting. Building SCIM directly means publishing your own contract (urn:ietf:params:scim:schemas:extension:yourapp:2.0:User) and owning every per-IdP quirk: Entra wrapping attributes inside the enterprise extension URN, Okta surfacing them at the top level, Rippling capping the number of custom fields, varying PATCH compliance across providers. With WorkOS, you skip that machinery, and customers integrate with the WorkOS endpoint rather than one you maintain.
All WorkOS products, including Directory Sync, are available in staging to try at no cost. Define your custom attributes, configure role mapping, and connect a test directory the same day.