SCIM provisioning with Entra ID and Okta
Let one organization's Microsoft Entra ID or Okta create, update, deactivate, and delete its people and groups in Better IAM through a SCIM 2.0 connection, with attribute and group-to-role mapping.
This guide lets one customer organization, Acme, manage its members from its own directory, Microsoft Entra ID or Okta, through . The directory creates an account before a person's first day, updates their name, title, department, and manager as they change, and deactivates them the moment they leave, even if they never sign in again. You will build:
- a SCIM service mounted in a Next.js app at
/api/iam/scim/v2, with directory fields copied into identity attributes; - a provisioning settings page where Acme's administrator creates a connection and receives its bearer token once;
- the Entra ID or Okta provisioning configuration, with an attribute mapping Better IAM accepts;
- group-to-role mappings, so membership in a directory group grants roles in your product;
- token rotation and revocation, and a clear picture of what deactivation and deletion do.
Prerequisites
- The app from Build a multi-tenant B2B SaaS, or any Next.js app using
@better-iam/next, with an organization (acme), its owner, and the roles you want to map (Admin, Member). - A public HTTPS origin for the app, such as
https://app.example.com. Entra ID and Okta call your SCIM endpoint from the internet; for local testing, run the app behind an HTTPS tunnel. - Administrator access to the Entra ID tenant (to create an enterprise application) or to the Okta organization (to create an app integration with SCIM provisioning).
Declare the attributes you will receive
Directory fields become typed identity attributes, which policies read as principal.{name}. Declare the ones you
map in the options module:
export default {
// ...database, secret, baseURL, authentication, resourceTypes, resolveResource
permissions: {
resourceTypes: {
project: { actions: ['projects:read', 'projects:write', 'projects:delete'] },
},
identityAttributes: { title: 'string', department: 'string', costCenter: 'string' },
},
};Mount the SCIM service
npm i @better-iam/scimCreate the service with the host callbacks from iam.protocolHost, put both of its route sets under the IAM route
that Next.js serves (/api/iam), and mount it:
import { betterIam } from '@better-iam/server';
import { createScimService } from '@better-iam/scim';
import config from '../better-iam.config.mjs';
export const iam = betterIam(config);
export const scim = createScimService({
...iam.protocolHost,
basePath: '/api/iam/scim/v2',
adminBasePath: '/api/iam/scim-admin',
mapAttributes: ({ title, enterprise }) => ({
...(title ? { title } : {}),
...(typeof enterprise?.department === 'string' ? { department: enterprise.department } : {}),
...(typeof enterprise?.costCenter === 'string' ? { costCenter: enterprise.costCenter } : {}),
}),
});
iam.useProtocol(scim);Add @better-iam/scim to serverExternalPackages in next.config.mjs next to @better-iam/server.
| Option | Type | Default | Description |
|---|---|---|---|
basePath | string | '/scim/v2' | Where the endpoints identity providers call are served. Must be absolute. |
adminBasePath | string | '/scim/admin' | Where the JSON administration routes for your admin UI are served. Must not overlap basePath. |
mapAttributes | (user: ProvisionedUser) => Record<string, unknown> | undefined | none | Turns provisioned fields into declared identity attributes. Return undefined to leave attributes untouched. |
mapManager | boolean | true | Sets each person's managerId from the enterprise extension's manager. |
unsupportedAttributes | 'ignore' | 'reject' | 'ignore' | What happens to attributes Better IAM does not store, such as addresses or phoneNumbers: dropped, or refused with 400 mutability. See Map attributes. |
store, authorize, authenticate, validateIdentityAttributes, syncRoleMappings | host callbacks | from iam.protocolHost | Database access, permission checks, attribute validation, and the trusted callback that applies group role mappings. |
ProvisionedUser carries userName, displayName, externalId, active, title, emails, name, and
enterprise (the enterprise extension object).
Forward every SCIM method
The IAM API needs only GET, POST, and OPTIONS, but identity providers also send PUT, PATCH, and DELETE
to update, deactivate, and delete. Forward them to the same handler:
import { iamNext } from '@/lib/iam-next';
export const runtime = 'nodejs';
const handlers = iamNext.handlers();
export const GET = handlers.GET;
export const POST = handlers.POST;
export const OPTIONS = handlers.OPTIONS;
// SCIM updates, deactivations, and deletions.
export const PUT = handlers.POST;
export const PATCH = handlers.POST;
export const DELETE = handlers.POST;Each export calls the IAM handler with the request unchanged, whatever its method. The edge middleware's default
protect (from
createIamMiddleware) leaves /api/iam public, so the directory's requests reach the route without a session
cookie; a custom protect must do the same.
Create a connection
A connection is one directory's credential for one organization. Acme's administrator creates it on your provisioning settings page and copies two values into their directory: the connection's URL and its bearer token.
'use server';
import { iam, scim } from '@/lib/iam';
import { iamNext } from '@/lib/iam-next';
export async function createScimConnection(name: string) {
const session = await iamNext.requireSession();
const connection = await scim.createConnection(await iamNext.credential(), {
tenantId: session.session.tenantId,
name,
expiresIn: 180 * 86400, // seconds; 60 seconds to one year, 90 days when omitted
});
return {
url: `${iam.endpoint.origin}${connection.path}`, // https://app.example.com/api/iam/scim/v2/{id}
token: connection.token, // shown once: display it, never store it
expiresAt: connection.expiresAt,
};
}createConnection requires iam:scim:connections:create. The token is stored only as a hash, is restricted to this
connection and tenant, and expires after expiresIn seconds. iam.endpoint.origin is the origin from baseURL.
| Input | Type | Default | Description |
|---|---|---|---|
tenantId | string | required | The organization the directory provisions into. |
name | string | required | A label for administrators, such as Entra ID provisioning. |
expiresIn | number | 7776000 (90 days) | Token lifetime in seconds, from 60 seconds to one year. |
The same call is available to browser code as POST /api/iam/scim-admin/connections/create, which needs the
session cookie, X-Better-IAM: 1, and a JSON body.
Configure the directory
Give the directory administrator the connection URL and token, and have them enter these values. The field names are the ones the directory's admin console shows; consoles rename fields between versions, so match them by meaning.
- In Enterprise applications, create your own application (non-gallery) for your product.
- Open Provisioning and set Provisioning Mode to Automatic.
- Under Admin Credentials, set Tenant URL to the connection URL and Secret Token to the token, then press Test Connection.
- Under Mappings, review the user mapping. Entra's defaults (
addresses,phoneNumbers,preferredLanguage, and so on) can stay: Better IAM accepts and ignores attributes it does not store (see Map attributes). Add the enterprise extension attributes you want to map, such asdepartment. - Under Settings, choose the Scope, usually only assigned users and groups, and assign the people and groups who should be provisioned in Users and groups.
- Set Provisioning Status to On. To test one person without waiting for a cycle, use Provision on demand.
Entra ID's SCIM dialect is handled: case-insensitive operation names, "True" and "False" strings for booleans,
bare-ID manager values, and member removal by value.
The directory first searches for each person (GET /Users?filter=userName eq "..."), creates the ones missing, and
then keeps them updated. Every request runs in one transaction, and the connection's lastUsedAt is updated at most
once a minute, so the settings page can show whether the directory is still calling.
Map attributes
A SCIM user becomes an identity in the organization. Better IAM stores the fields below:
| SCIM attribute | Becomes | Notes |
|---|---|---|
userName | The connection's unique key | Required, unique per connection ignoring case. Used as the email when it is an email address and no emails are sent. |
emails (up to 20; value, type, primary, display) | The identity's email | The primary email, or the first one. Stored unverified. |
displayName | The identity's name | Falls back to userName. |
active | The identity's status | false disables the identity and revokes its sessions. |
externalId | Stored on the SCIM user | Case-exact; filterable. |
name (formatted, givenName, familyName, middleName, honorificPrefix, honorificSuffix) | Stored on the SCIM user | Returned and filterable. |
title | mapAttributes input | Copied to title by the mapping in this guide. |
Enterprise extension employeeNumber, costCenter, organization, division, department | mapAttributes input (enterprise) | Copied to declared attributes by mapAttributes. |
Enterprise extension manager | The identity's managerId | With mapManager (default on). The value may be the manager's SCIM ID, externalId, or userName. |
The enterprise extension schema is urn:ietf:params:scim:schemas:extension:enterprise:2.0:User. Everything else
directories commonly send is accepted and dropped, in creates, replaces, and PATCH operations alike:
- Read-only attributes (
id,meta,groups), which directories echo back in aPUT. RFC 7644 says to ignore them, so they are ignored whateverunsupportedAttributessays. APATCHwhose path targets one (replace meta.created) still fails with400. - Core User attributes Better IAM does not store:
nickName,profileUrl,userType,preferredLanguage,locale,timezone,password,phoneNumbers,ims,photos,addresses,entitlements,roles, andx509Certificates. APATCHsuch asreplace phoneNumbers[type eq "work"].valueis skipped while the rest of the request applies. - Extension schemas other than the enterprise extension, such as a custom Okta extension. Their URN is left out
of the stored
schemas.
An attribute no supported schema defines (a typo such as titel, or badge inside the enterprise extension) still
fails with 400 and scimType: mutability, so mapping mistakes surface in the directory's provisioning log. Set
unsupportedAttributes: 'reject' to refuse unstored attributes the same way.
The mapped attributes drive policy conditions, for example StringEquals: { 'principal.department': 'Finance' }.
See Conditions.
Map groups to roles
A directory group carries no authority by itself: the directory decides who is in "Engineering", and an administrator of your product decides what "Engineering" may do. After the directory has pushed its groups, list them and map each to roles:
'use server';
import { scim } from '@/lib/iam';
import { iamNext } from '@/lib/iam-next';
export async function listPushedGroups(connectionId: string) {
const session = await iamNext.requireSession();
// [{ id, groupId, displayName, externalId?, members, roleIds }]
return scim.listGroups(await iamNext.credential(), { tenantId: session.session.tenantId, connectionId });
}
export async function mapGroupToRoles(connectionId: string, scimGroupId: string, roleIds: string[]) {
const session = await iamNext.requireSession();
await scim.setRoleMappings(await iamNext.credential(), {
tenantId: session.session.tenantId,
connectionId,
groupId: scimGroupId, // the SCIM group's `id` from listGroups, not the local groupId
roleIds, // replaces the group's mapped roles; at most 100
});
}setRoleMappingsneedsiam:scim:mappings:updateon the connection andiam:bindings:createon every role, so nobody can map a role they could not grant directly. Protected roles, such as Owner, are refused.- The mapping creates group under the administrator's grant authority. Joining the group in the directory then grants the roles here, and leaving it removes them.
- The SCIM token can only move people in and out of groups an administrator mapped. It cannot create roles, policies, or bindings itself.
- Nested groups are not supported, and a group's members must belong to the same connection (up to 1000 member references per request).
Know what deprovisioning does
Directories end access in two ways: they deactivate (active: false) when someone is disabled or unassigned, and
some later delete the user. Better IAM handles both:
| Directory request | Effect in Better IAM |
|---|---|
PATCH or PUT with active: false | Disables the identity and removes its sessions, API keys, remembered MFA devices, and pending sign-in challenges in the same transaction. License seats follow the status. Audited as iam:scim:UpdateUser. |
PATCH or PUT with active: true | Re-enables the identity, but only if the directory disabled it. An identity an administrator disabled stays disabled. |
DELETE /Users/{id} | Disables the identity, revokes its sessions, and removes it from the connection's groups (and so from the roles mapped to them). The disabled identity is kept as a historical principal, so audit history still names it. Audited as iam:scim:DeleteUser. |
DELETE /Groups/{id} | Removes the local group and the bindings its mapping created. |
| Connection revoked | The token stops working at once, and the connection's mapped bindings are removed. Provisioned identities stay as they are. |
- Protected identities. Owners and root administrators cannot be changed by SCIM (
403 mutability), so a directory mistake cannot lock the organization out. - Administrator deletions win. An identity an administrator deleted is never updated, reactivated, or re-added
to groups by SCIM. A SCIM
DELETEof it still succeeds. - Manager links. Deleting a manager through SCIM releases the reports SCIM linked to them.
To end everything else a leaver holds in one audited step (role bindings outside mapped groups, group memberships,
and owned resources handed to a successor), run identities.offboard
as well. The Enterprise onboarding walkthrough
shows how a SCIM deactivation reaches downstream apps and OAuth clients.
Rotate or revoke the token
Tokens expire; put a reminder on expiresAt, or rotate earlier after a leak or a staff change:
'use server';
import { scim } from '@/lib/iam';
import { iamNext } from '@/lib/iam-next';
export async function rotateScimToken(connectionId: string) {
const session = await iamNext.requireSession();
const rotated = await scim.rotateToken(await iamNext.credential(), {
tenantId: session.session.tenantId,
connectionId,
});
return { token: rotated.token, expiresAt: rotated.expiresAt }; // paste into the directory at once
}
export async function stopProvisioning(connectionId: string) {
const session = await iamNext.requireSession();
await scim.revokeConnection(await iamNext.credential(), { tenantId: session.session.tenantId, connectionId });
}| Method | Permission | Effect |
|---|---|---|
scim.listConnections(credential, { tenantId }) | iam:scim:connections:read | Name, path, expiry, revocation, last use and rotation times, user and group counts, and role mappings. Never the token. |
scim.rotateToken(credential, { tenantId, connectionId, expiresIn? }) | iam:scim:credentials:create | A new token, returned once. The previous token stops working immediately; provisioned users, groups, and mappings are kept. |
scim.revokeConnection(credential, { tenantId, connectionId }) | iam:scim:connections:delete | Invalidates the token immediately and removes the connection's mapped bindings. |
Verify it works
Test the endpoint with the token before the directory does. Replace the URL and token with your connection's:
export SCIM_URL="https://app.example.com/api/iam/scim/v2/<connectionId>"
export SCIM_TOKEN="<token>"
# Create a person.
curl -s -X POST "$SCIM_URL/Users" \
-H "Authorization: Bearer $SCIM_TOKEN" -H "Content-Type: application/scim+json" \
-d '{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User","urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"],
"userName":"ada@acme.com","displayName":"Ada Lovelace","active":true,
"emails":[{"value":"ada@acme.com","type":"work","primary":true}],
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User":{"department":"Research"}}'
# Find her the way a directory does.
curl -s "$SCIM_URL/Users?filter=userName%20eq%20%22ada@acme.com%22" -H "Authorization: Bearer $SCIM_TOKEN"
# Deactivate her (use the id from the create response).
curl -s -X PATCH "$SCIM_URL/Users/<id>" \
-H "Authorization: Bearer $SCIM_TOKEN" -H "Content-Type: application/scim+json" \
-d '{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[{"op":"replace","value":{"active":false}}]}'- The create answers
201with anidand anETag.identities.listfor Acme shows Ada withattributes.department: 'Research'. - The search answers a
ListResponsewith one result. - After the
PATCH, Ada's identity isdisabledand any session she had is gone. - In the directory, assign a test person and run a provisioning cycle (Provision on demand in Entra ID). The
audit log shows
iam:scim:CreateUserwith the actorscim:{connectionId}, andscim.listConnectionsshows a recentlastUsedAt. - Add the test person to a mapped group: they receive the group's roles. Remove them: the roles go.
Troubleshooting
Next steps
Let the same people sign in with their directory account.
SCIM inbound referenceFilters, PATCH, Bulk, ETags, limits, and the administration routes.
Enterprise onboardingDomain, SSO, SCIM in and out, and offboarding for one customer.
SCIM outboundPush the people you manage on to other SaaS applications.
Better IAM is created by Sean Filimon
Last updated
SAML single sign-on with Okta
Connect one organization's Okta to its Better IAM tenant with a tenant-managed SAML connection, verified domain, just-in-time accounts, default access, and enforced SSO.
Protect an API with OAuth
Run Better IAM as the OAuth authorization server, declare a separate API as a resource server, issue audience-restricted access tokens, and verify them with scopes in a Hono or Express service.