Connect Microsoft Entra ID and Okta over SCIM
Connect Microsoft Entra ID and Okta over SCIM
This recipe connects a customer's identity provider to the platform's inbound SCIM 2.0 server, so users and groups are provisioned, updated and deprovisioned from the IdP. See Inbound SCIM provisioning server for everything the server does; this page is the setup and the IdP-side settings.
Both IdPs are covered by replay tests in this package
(tests/Feature/Api/ScimEntraConformanceTest.php,
tests/Feature/Api/ScimOktaConformanceTest.php): the request bodies are the ones each
vendor documents, verbatim.
1. Register a directory and hand out its token
One directory per organization connection. The token is shown once.
use Cbox\Id\Directory\Contracts\Directories;
$registered = app(Directories::class)->register($organization->id, 'Corporate Entra ID');
$registered->token; // "scim_…" — give it to the IdP admin; it is never shown again
The base URL is served on the environment's host (the same host as its issuer), and the token only ever addresses this one directory.
Check it before configuring anything on the IdP side:
curl -sS "$BASE/ServiceProviderConfig" -H "Authorization: Bearer $TOKEN"
The response advertises patch, filter (maxResults: 200), sort, etag and
bulk (with its limits) as supported, and changePassword as not.
2. Microsoft Entra ID
In the Entra admin center, on the enterprise application: Provisioning → provisioning mode Automatic.
| Setting | Value |
|---|---|
| Tenant URL | https://<environment host>/scim/v2 |
| Secret Token | the scim_… token |
Test Connection reads a user and a group with a filter; both must answer 200
(an empty ListResponse is fine).
Mappings
- Matching attribute for users:
userName(the default) orexternalId. Both are filterable witheq. If you key uniqueness on the work email instead, Entra queriesemails[type eq "work"].value eq "…"— that works too. - Matching attribute for groups:
displayName. Group display names are unique per directory (any case); a duplicate create is409 uniqueness, which is what Entra expects. - Attributes the platform stores:
userName,externalId,active,displayName,name.givenName,name.familyName,name.formatted, the work email, and the Enterprise User attributes (employeeNumber,costCenter,organization,division,department,manager). Entra's default mapping also sends phone numbers, addresses,titleand others — those are accepted and ignored, never refused, so a deprovision carrying them still deactivates the user. - Do not map
externalIdto something that changes. It is the provisioning key and is immutable once provisioned; a PATCH that changes it is400 mutability.
The compliance flag
Entra's PATCH requests come in two shapes, depending on whether the tenant URL carries
Microsoft's ?aadOptscim062020 flag. The platform accepts both, so the flag is
optional:
| Request | Without the flag | With the flag |
|---|---|---|
| Deactivate | {"op":"Replace","path":"active","value":"False"} |
{"op":"replace","path":"active","value":false} |
| Several attributes | one Replace per path |
one pathless replace with name.givenName, URN-qualified keys, … |
| Remove a member | {"op":"Remove","path":"members","value":[{"value":"<id>"}]} |
{"op":"remove","path":"members[value eq \"<id>\"]"} |
Deprovisioning
Entra deprovisions with active: false (and, depending on the app's settings, a
DELETE). Both revoke every session of the person immediately. A DELETE here is a
soft deprovision: the user still reads back, with active: false, and Entra can
re-activate it later with a PATCH.
3. Okta
In the Okta admin console, on the app integration's Provisioning tab, enable SCIM provisioning and configure the integration:
| Setting | Value |
|---|---|
| SCIM connector base URL | https://<environment host>/scim/v2 |
| Unique identifier field for users | userName |
| Supported provisioning actions | Import New Users and Profile Updates, Push New Users, Push Profile Updates, Push Groups (as needed) |
| Authentication Mode | HTTP Header — the scim_… token as the Bearer token |
Test Connector Configuration reads GET /Users?startIndex=1&count=2 (and
GET /Groups?startIndex=1&count=100 when group import is on).
What Okta then sends, and the platform answers:
- Existence check —
GET /Users?filter=userName eq "…"&startIndex=1&count=100.userNameis matched case-insensitively; no match is200withtotalResults: 0. - Create —
POST /Userswithpassword,localeand an emptygroups: the password is ignored (not part of the profile). Creating a user that already exists is409 uniqueness. - Profile update — a
GETfollowed by a fullPUTof the resource (or a PATCH). - Deactivate / reactivate —
PATCHwith{"op":"replace","value":{"active":false}}. Okta never sendsDELETEfor users. - Push Groups — create, a pathless rename (
{"id": …, "displayName": …}), membership asremovewithmembers[value eq "<id>"]plusaddonmembers, or areplaceofmembers, andDELETEwhen a pushed group is unlinked with delete. - Paging —
startIndexadvanced whiletotalResultsexceeds what was read. The order is stable across requests (byid, or bysortBywithidbreaking ties).
4. What neither IdP uses — but you can
Microsoft Entra ID and Okta both provision with filters and PATCH, one request per change. The platform also implements, for other clients and your own tooling:
/Bulk— many operations, withbulkIdcross-references, in one request (cbox-id.scim.bulk.max_operations,…max_payload_size).- ETags —
meta.version/ETagon every resource,If-Match→412,If-None-Match→304. - Sorting —
sortBy/sortOrderon/Usersand/Groups. - The full filter grammar —
not, grouping, value paths, URN-qualified paths.
Troubleshooting
| Symptom at the IdP | Cause | Fix |
|---|---|---|
401 on Test Connection |
Wrong or revoked token, or the URL points at another environment's host. | Use the token issued for this directory, on its environment's host. |
409 uniqueness on create |
The user already exists in the directory (same externalId, or a userName differing only in case), or the email belongs to another account on the platform. |
Let the IdP match the existing user (its existence check), or resolve the duplicate account. |
409 uniqueness on a group |
Another group has that displayName (any case). |
Rename one of them at the IdP. |
400 invalidFilter |
A filter on an attribute the platform does not store (title, phoneNumbers, …). |
Change the matching attribute to one the platform stores. |
400 mutability |
A PATCH or PUT changing externalId. |
Map externalId to an immutable source attribute. |
429 |
The SCIM surface is rate-limited (throttle:120,1). |
Nothing — both IdPs back off and retry on the Retry-After. |