SCIM Provisioning
Eneo supports SCIM 2.0 (RFC 7644) for automated user and group provisioning from your identity provider. This guide covers how to enable SCIM for a tenant, configure Azure Entra ID, and understand how Eneo behaves when SCIM interacts with existing accounts.
Overview
SCIM (System for Cross-domain Identity Management) is a standardized push channel where your identity provider proactively keeps Eneo up to date. It complements OIDC just-in-time (JIT) provisioning, which only creates users when they sign in.
SCIM provisioning in Eneo provides:
- Automatic deprovisioning — users disabled in your IdP are deactivated in Eneo without manual intervention
- Group synchronization — manage Eneo access through existing directory groups
- Pre-provisioning — give users access before their first sign-in
- Multi-tenant isolation — each Eneo tenant has its own SCIM endpoint and bearer token
SCIM vs JIT provisioning
| Feature | JIT (OIDC) | SCIM |
|---|---|---|
| User created on first login | Yes | No (pushed by IdP) |
| User deactivated when disabled in IdP | No | Yes |
| Groups synchronized | No | Yes |
| Visible to admins before login | No | Yes |
The two can coexist. SCIM reconciles existing JIT-created users automatically — no duplicates are created.
How it works
Eneo exposes a SCIM 2.0 server at /scim/v2, separate from the main /api/v1/ API. Authentication is per-tenant: each tenant has its own bearer token, and the token itself identifies which tenant to write to.
Endpoint summary
| Method | Path | Purpose |
|---|---|---|
| GET | /scim/v2/ServiceProviderConfig | Discover server capabilities |
| GET | /scim/v2/Schemas | User and Group attribute schemas |
| GET | /scim/v2/ResourceTypes | Available resource types |
| POST / GET / PUT / PATCH / DELETE | /scim/v2/Users | User CRUD |
| POST / GET / PUT / PATCH / DELETE | /scim/v2/Groups | Group CRUD |
| POST | /scim/v2/Bulk | Bulk operations (up to 100 ops, 1 MiB payload) |
DELETE is a soft delete — the underlying row is marked state=DELETED and retained (for users, a deleted_at timestamp is also set). The audit trail is preserved.
Supported capabilities
The server advertises the following in ServiceProviderConfig:
- PATCH: supported (per RFC 7644 §3.5.2)
- Bulk: up to 100 operations per request, 1 MiB max payload
- Filter: supported, max 200 results
- Sort: supported
- changePassword: not supported (passwords are managed in the IdP)
- etag: not supported
Step 1: Generate a SCIM token
SCIM tokens are issued per tenant through the sysadmin API. They are required to authenticate any request to /scim/v2.
A token is shown only once on creation. Eneo stores only a SHA-256 hash of the token — there is no way to retrieve it later. If a token is lost, revoke it and issue a new one.
Create a token
curl -X POST https://your-domain.com/api/v1/sysadmin/tenants/{tenant-id}/scim-token \
-H "Authorization: Bearer ${ENEO_SUPER_API_KEY}"The response (201 Created) contains the raw token:
{
"tenant_id": "f1e2d3c4-...",
"token": "scim_4f9c8a2b1d7e3..."
}Store this token securely. Configure it in your IdP as described below.
Check whether a token is active
curl https://your-domain.com/api/v1/sysadmin/tenants/{tenant-id}/scim-token \
-H "Authorization: Bearer ${ENEO_SUPER_API_KEY}"Returns { "tenant_id": "...", "is_active": true } (or "is_active": false). The actual token is never returned.
Revoke a token
curl -X DELETE https://your-domain.com/api/v1/sysadmin/tenants/{tenant-id}/scim-token \
-H "Authorization: Bearer ${ENEO_SUPER_API_KEY}"After revocation, all SCIM requests to that tenant return 401 Unauthorized. To rotate a token, revoke the old one and create a new one.
Step 2: Configure Azure Entra ID
Azure Entra ID can provision users and groups to Eneo over SCIM. The setup uses Entra’s Enterprise applications → Provisioning feature.
Create an Enterprise application
- Go to Azure Portal
- Navigate to Microsoft Entra ID → Enterprise applications → New application
- Choose Create your own application
- Name it (for example,
Eneo SCIM) and select Integrate any other application you don’t find in the gallery (Non-gallery) - Click Create
Configure provisioning
- In the new application, go to Provisioning → Get started
- Set Provisioning Mode to Automatic
- Under Admin Credentials, enter:
- Tenant URL:
https://your-domain.com/scim/v2 - Secret Token: the SCIM token from Step 1
- Tenant URL:
- Click Test Connection — you should see a success message
- Click Save
Assign users and groups
- Go to Users and groups in the same application
- Click Add user/group and assign the users or directory groups that should be synced to Eneo
- Return to Provisioning and click Start provisioning
Entra will perform an initial sync followed by periodic delta syncs (default: every 40 minutes).
Attribute mappings
Entra’s default User mappings work for Eneo. The minimum required attributes are:
userName— Eneo’s username (typically the user’s email)emails[type eq "work"].value— primary emailexternalId— Entra’s immutable object IDactive— controls deprovisioning
For Group mappings, the defaults also work. Members are referenced by their externalId.
Step 3: Verify the integration
- Go to Provisioning logs in your Enterprise application
- Confirm that initial sync events show Status: Success
- Log into Eneo as an admin and verify that the assigned users appear under Organization → Users
- Disable a test user in Entra and wait for the next sync — the user should appear as inactive in Eneo
- Re-enable the user in Entra — they should be reactivated in Eneo
Sign-in prerequisites when paired with OIDC
SCIM provisions users into Eneo, but users still need to sign in through OIDC. Eneo matches a signing-in user to their SCIM-provisioned row by email — specifically, the email claim in the ID token issued by your identity provider.
Azure Entra ID derives the email claim from the user’s mail attribute. If mail is empty on the directory record, the ID token arrives without an email claim and Eneo rejects the sign-in:
401 Unauthorized
{ "detail": "Email claim not found in ID token" }Checklist for a working email claim
To ensure every provisioned user can sign in, confirm both of the following in Azure:
- The user has a
mailattribute. Amailvalue must be present on the directory record (synced from on-premises AD, or populated in Entra/Exchange Online). Users without it are provisioned by SCIM but cannot sign in. - The App Registration emits the
emailclaim. In the App Registration used for OIDC sign-in, go to Token configuration → Add optional claim → ID, addemail, and save. Without this, the ID token omitsemaileven whenmailis set.
To find assigned users who are missing mail before they hit the sign-in error, use Microsoft Graph PowerShell:
Connect-MgGraph -Scopes "User.Read.All"
# List users whose `mail` is empty — these will fail OIDC sign-in
Get-MgUser -All -Property "displayName,userPrincipalName,mail" |
Where-Object { -not $_.Mail } |
Select-Object DisplayName, UserPrincipalNamePopulate mail for any users the query returns, then retry sign-in.
How Eneo handles existing users
When you connect SCIM to an Eneo tenant that already has users (for example, from prior JIT provisioning), Eneo reconciles them automatically rather than creating duplicates.
The reconciliation flow on first provisioning:
- SCIM looks up a user by
userName— if found, the user is updated. - If not found and
userNamecontains an email, SCIM falls back to matching by email. - On an email match, the existing row is updated with the new
externalIdanduserNamefrom the IdP. - An audit event
SCIM_USER_RECONCILEDis logged so the reconciliation is visible.
This means initial Entra provisioning is safe to run against any existing tenant.
Multi-tenant behavior and the email constraint
Eneo enforces a global uniqueness constraint on active user emails: an email address can only belong to one active user across the entire installation, regardless of tenant. This applies to both OIDC and SCIM.
If SCIM tries to provision an email that is already active in another tenant, the server returns 409 Conflict:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "409",
"scimType": "uniqueness",
"detail": "Email 'user@example.com' is already in use by another tenant"
}For single-tenant installations this is invisible. For multi-tenant installations, this only affects users (such as consultants) who would otherwise need accounts in multiple tenants simultaneously.
Provisioning scope and deprovisioning triggers
Azure Entra ID’s SCIM provisioning has two scope modes that decide which users are in scope for a given Enterprise application. The mode you pick changes what user actions in Azure send active=false to Eneo — which is often counter-intuitive.
The two scope modes
Set under Enterprise applications → [app] → Provisioning → Settings → Scope:
| Scope | Who gets synced | When does Azure send active=false? |
|---|---|---|
| Sync only assigned users and groups | Only users on the app’s Users and groups list | When you remove a user from the assignment list (or remove a user from an assigned group) |
| Sync all users and groups | Every user in the Azure tenant | When you delete the user from Azure entirely |
A common surprise: in either mode, just unchecking Account Enabled on a user does not by itself send active=false. Azure derives active from whether the user is in scope, not from the enabled flag.
Safety net: “Prevent accidental deletion”
Under Provisioning → Settings, the “Prevent accidental deletion” toggle (with an Accidental deletion threshold value) acts as a circuit breaker: if a sync cycle would deprovision more users than the threshold, Azure pauses the cycle, sends an email to the configured notification address, and waits for the operator to review and approve or reject the deletions before any users are actually deprovisioned.
This is not “ignore deletions” — it’s “stop and ask” when the deletion count crosses the line. Useful when changing scope or assignments on an already-provisioned tenant, where a mistake could otherwise mass-deprovision users.
Triggering a sync immediately
Periodic sync runs every ~40 minutes by default. To trigger an action without waiting:
- Provision on demand: select a specific user or group in Azure (Enterprise app → Provisioning → Provision on demand). Bypasses the periodic cycle. Note: requires the user to still exist in Azure — you cannot target a user that has already been deleted from the directory.
- Restart provisioning: forces a full delta cycle for everything in scope.
If a user has been deleted in Azure entirely (so they’re no longer selectable in Provision on demand), Eneo will deprovision them at the next periodic sync; there is no Azure-side button to push that immediately.
Deprovisioning and reactivation
When the IdP sets active=false on a user (typically because the user was disabled or left the organization), Eneo performs a soft delete:
- The user’s
stateis set toDELETED deleted_atis set to the current time- The user can no longer sign in
- The row is preserved so the audit trail remains intact
This operation is idempotent — repeated active=false updates have no further effect.
If the same user is later re-enabled in the IdP (active=true), Eneo reactivates the existing row rather than creating a new one:
statereturns toACTIVEdeleted_atis cleared- The user’s
externalIdis updated if it changed - An audit event
SCIM_USER_REACTIVATEDis logged
Hard deletion is not available over SCIM by design — preserving the audit trail is required for compliance.
Groups
Groups created via SCIM are stored in the same user_groups table as native Eneo groups, with no separate origin marker. Eneo does not reliably distinguish SCIM-managed groups from locally-created ones: a group’s external_id is the only hint, but it is not sent by every IdP (Okta omits it — see Group identity), is not exposed in the admin UI, and is never used to match groups.
- Group membership is fully managed by the IdP. Changes made directly in Eneo to SCIM-managed groups may be overwritten by the next sync.
- Members must belong to the same tenant. Cross-tenant memberships are rejected with
400 Bad Request. - Groups support soft delete with the same semantics as users.
Group identity: Eneo matches groups by display name
This is the single most important thing to get right when designing your group structure on the IdP side.
Unlike users — which Eneo matches by userName and email, and for which externalId is enforced unique when present — Eneo identifies a group solely by its displayName within a tenant. A group’s externalId is stored and returned, but it is not used to match, look up, or distinguish groups, and there is no uniqueness constraint on it. The unique key for an active group is (displayName, tenant).
This design is deliberate: it is the lowest common denominator that works across identity providers, because not every IdP sends externalId for groups:
| IdP | Sends externalId for groups? | Matches groups on |
|---|---|---|
| Azure Entra ID | Yes (the group’s immutable object ID) | Its own object ID, mapped to externalId |
| Okta | No (not sent for “Push Groups” by default) | displayName |
If Eneo matched groups on externalId, Okta provisioning would create a duplicate group on every sync, because there is no stable ID to match against. Matching on displayName works for both — but it pushes the responsibility for unique, stable group names onto whoever configures the provisioning scope on the IdP side.
Why this matters: two groups with the same name
Because the display name is the identity, two groups with the same displayName cannot coexist in one Eneo tenant — even if the IdP considers them distinct (for example, two Entra groups with different object IDs but the same name). The outcome is not “last one wins”; it is one of two failure modes, and both are easy to miss:
- Both groups active — the second group’s provisioning is rejected with
409 Conflict. The second group is never created in Eneo, and its members silently never receive their access. A WARNING is logged (scim.group.conflict). - A same-named group was previously soft-deleted — provisioning the new group reactivates and reuses the old, deleted row rather than creating a fresh group. The IdP receives back the old group’s Eneo ID and inherits its audit history; only the membership is reset. This can surface as a group that appears to have “come back from the dead.”
Recommendations for client-side setup
Treat group naming as part of designing your provisioning structure, not an afterthought:
- Keep group display names unique within the set of groups you push to a single Eneo tenant. Watch especially for generic names like
AdministratorsorUsersthat may exist in multiple OUs. - Keep names stable. Entra handles a rename by sending a
PATCHagainst Eneo’s resource ID, so renames work there. Okta, which has no stable group ID, is more fragile — a rename can look like a new group plus an orphaned old one. - Monitor for conflicts after each provisioning run. Because a name collision fails silently from the admin’s point of view, check the backend logs for
scim.group.conflict(see Troubleshooting) after the initial sync and after any scope change.
Bulk operations
Eneo supports the SCIM /Bulk endpoint for batched changes, which Entra and other IdPs use to reduce request volume.
- Up to 100 operations per request
- Up to 1 MiB total payload size
- Each operation runs in its own savepoint, so a failure in one operation does not corrupt earlier successes
failOnErrorsaborts the batch after a configurable number of errorsbulkIdreferences let you create a user and reference them as a group member in the same request
These behaviors are advertised in ServiceProviderConfig and follow RFC 7644 §3.7.
Why these limits?
The 100-operation and 1 MiB limits are aligned with the conservative side of common SCIM server defaults (Azure Entra ID, Okta, Ping, Microsoft Identity Manager). They balance two competing concerns:
- Large enough to be useful: matches the chunk sizes that Entra and Okta use by default, so well-behaved IdPs rarely need to split a sync into more requests than necessary.
- Small enough to bound resource use: a single bulk fits comfortably within one database transaction and one worker without risking long-running locks or large memory spikes.
Both limits are independent — whichever is reached first triggers rejection. For example, a request with 50 operations totalling 1.5 MiB is rejected on payload size, and a request with 150 small operations is rejected on operation count.
What happens when a limit is exceeded?
Per RFC 7644 §3.7.3, Eneo returns HTTP 413 Payload Too Large with a SCIM error response:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "413",
"detail": "Bulk request has 150 operations, exceeds maxOperations (100)"
}Clients reading ServiceProviderConfig (which all standard IdPs do) will automatically chunk requests to stay within these limits, so you should not see 413 responses during normal operation. Receiving one usually indicates a misconfigured client or a custom integration that does not consult discovery.
How the size limit is enforced
The 1 MiB payload limit is checked server-side against the request’s Content-Length header. This catches well-behaved clients that send the header correctly — which all standard SCIM IdPs do.
Clients that omit the header (for example, using chunked transfer encoding) or that report it incorrectly can bypass the application-level check. In those cases the limit is enforced by the upstream reverse proxy in front of Eneo (Traefik in the reference deployment), which applies its own body-size limit before the request reaches the application.
For deployments exposing SCIM to untrusted networks, ensure the reverse proxy is configured with a payload limit at or below 1 MiB. The application-level check is best-effort and should not be relied on as the only defense against oversized payloads.
Audit logging
All SCIM mutations are recorded in the Eneo audit log alongside other administrative actions. Relevant action types include:
SCIM_USER_PROVISIONED— new user createdSCIM_USER_RECONCILED— existing user matched by email and linked to IdPSCIM_USER_REACTIVATED— soft-deleted user restoredSCIM_USER_UPDATED— user attributes changedSCIM_USER_DEPROVISIONED— user soft-deleted (deactivated)SCIM_GROUP_CREATED,SCIM_GROUP_REACTIVATED,SCIM_GROUP_UPDATED,SCIM_GROUP_DELETED— analogous events for groupsSCIM_TOKEN_CREATED,SCIM_TOKEN_REVOKED— sysadmin token lifecycle
Each event records the acting principal as actor.type = "scim" with the bearer token’s tenant context. Sysadmin token operations are recorded with actor_type = "SYSTEM".
See the Audit Logging guide for how to view and export these events.
Troubleshooting
Test Connection fails in Entra
Check the Tenant URL:
- Must end with
/scim/v2(no trailing slash) - Must use HTTPS in production
- Must be reachable from Azure (test from outside your network)
Verify the token works:
curl https://your-domain.com/scim/v2/ServiceProviderConfig \
-H "Authorization: Bearer ${SCIM_TOKEN}"You should receive a JSON ServiceProviderConfig document. A 401 indicates the token is wrong or revoked.
Provisioning logs show 409 Conflict
A 409 typically means one of:
- The user’s email already belongs to a different tenant in this Eneo installation
- The
externalIdcollides with an existing user
Check the detail field in the SCIM error response to determine the cause. Reconciliation only runs when the existing user has no externalId set — if a different IdP previously set one, it must be cleared first.
OIDC sign-in fails with “Email claim not found in ID token”
Azure omits the email claim from the ID token if either:
- The signing-in user has no
mailattribute set in the directory, or - The App Registration is not configured to include
emailas an optional claim
See Sign-in prerequisites when paired with OIDC for the full checklist and a PowerShell snippet to find users missing the mail attribute.
Users are not deactivated when removed from Entra
What triggers deprovisioning depends on the provisioning scope of the Enterprise application — and unchecking Account Enabled on a user is not one of the triggers. Azure derives active from whether the user is in scope, not from the enabled flag.
The common deprovisioning triggers:
- Scope = “Sync only assigned users and groups”: removing the user from the app’s Users and groups list sends
active=falseat the next sync. - Scope = “Sync all users and groups”: deleting the user from Azure entirely sends
active=falseat the next sync.
See Provisioning scope and deprovisioning triggers for the full picture and the “Prevent accidental deletion” safety net.
Group members are missing
Group members are referenced by externalId. A user must have been synced and have an externalId set before they can be added to a group. Check the user provisioning log first — group sync runs after user sync.
A group is missing, or its members have no access
Eneo identifies groups by displayName, not by externalId (see Group identity). The usual cause is a name collision: two groups in scope share a display name, so the second one fails to provision.
This fails quietly — the IdP provisioning log may show success while Eneo rejected the group. Check the backend logs:
docker compose logs backend | grep scim.group.conflictA scim.group.conflict entry names the display_name that collided. Resolve it by giving the groups distinct display names in the IdP, then re-run provisioning.
Check backend logs
For server-side errors:
docker compose logs backend | grep scimSCIM-specific log entries are tagged with scim.user.* or scim.group.*.
Security considerations
- Token rotation: SCIM tokens do not expire. Rotate them periodically using the revoke + create flow.
- Storage: tokens are stored as SHA-256 hashes only. Treat the raw token as a long-lived credential.
- Transport: always use HTTPS. The token is sent in the
Authorizationheader on every request. - Scope: a SCIM token is scoped to a single tenant. It cannot access
/api/v1/. - Network: if your Eneo deployment is on a private network, ensure that the IdP can reach
/scim/v2either via VPN, IP allowlisting, or a public reverse proxy with TLS.
Need Help?
- See the Authentication & OIDC guide for SSO setup that pairs with SCIM
- Check the Audit Logging guide to monitor SCIM activity
- Visit GitHub Issues
- Contact us at digitalisering@sundsvall.se (public sector organizations)