Enterprise IT departments have one non-negotiable requirement before approving any SaaS application: users and groups must be manageable from their identity provider. If an employee is terminated, their access must be revoked automatically within minutes — not dependent on a manual process in your app. If a new hire joins, they should have the right permissions on day one without anyone touching your admin panel. That mechanism is SCIM: System for Cross-domain Identity Management.
Salesforce has it. Slack has it. GitHub Enterprise has it. When a mid-market security-conscious company asks "do you support SCIM provisioning?" during a procurement call, the answer determines whether the deal closes. It is not a nice-to-have.
What SCIM actually is
SCIM 2.0 (RFC 7642, 7643, 7644) is a REST API specification. Your application exposes a set of HTTP endpoints, and the enterprise identity provider — typically Okta, Azure AD, or a similar IdP — calls those endpoints to synchronize users and groups. The IdP is the source of truth; your application is the downstream consumer.
The core resource types are Users and Groups. The spec defines a JSON schema for each and a set of operations: create, read, update, patch, delete, and list with filtering. Implementations that handle these correctly will work with all major IdPs because the IdP does the translation between its own data model and the SCIM schema.
The Users endpoint
At minimum, your SCIM server must implement:
GET /scim/v2/Users— list users, with support forfilterquery parameterGET /scim/v2/Users/{id}— fetch a single userPOST /scim/v2/Users— create a userPUT /scim/v2/Users/{id}— replace a user (full update)PATCH /scim/v2/Users/{id}— partial update, used for activating/deactivatingDELETE /scim/v2/Users/{id}— deprovision a user
// Express.js SCIM Users endpoint skeleton
import express from 'express';
const router = express.Router();
// SCIM requires this content-type
const SCIM_CONTENT_TYPE = 'application/scim+json';
// GET /scim/v2/Users
router.get('/Users', async (req, res) => {
const { filter, startIndex = 1, count = 100 } = req.query;
let query = db.users.where({ tenantId: req.tenant.id });
// SCIM filter parsing (simplified — use a library in production)
if (filter) {
const emailMatch = filter.match(/userName eq "([^"]+)"/);
if (emailMatch) query = query.where({ email: emailMatch[1] });
}
const [users, total] = await Promise.all([
query.offset(startIndex - 1).limit(count),
query.count()
]);
res.type(SCIM_CONTENT_TYPE).json({
schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
totalResults: total,
startIndex: Number(startIndex),
itemsPerPage: users.length,
Resources: users.map(toScimUser)
});
});
// POST /scim/v2/Users
router.post('/Users', async (req, res) => {
const { userName, name, emails, active } = req.body;
const email = emails?.find(e => e.primary)?.value || userName;
const existing = await db.users.findOne({ email, tenantId: req.tenant.id });
if (existing) {
// Return 409 if email already exists
return res.status(409).type(SCIM_CONTENT_TYPE).json({
schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'],
status: '409',
detail: 'User already exists'
});
}
const user = await db.users.create({
tenantId: req.tenant.id,
email,
firstName: name?.givenName,
lastName: name?.familyName,
active: active !== false,
scimProvisioned: true
});
res.status(201).type(SCIM_CONTENT_TYPE).json(toScimUser(user));
});
// PATCH /scim/v2/Users/:id — used for deactivation
router.patch('/Users/:id', async (req, res) => {
const user = await db.users.findOne({ id: req.params.id, tenantId: req.tenant.id });
if (!user) return res.status(404).json({ status: '404' });
for (const op of req.body.Operations) {
if (op.op === 'Replace' && op.path === 'active') {
await user.update({ active: op.value });
}
// Handle other attribute updates...
}
res.type(SCIM_CONTENT_TYPE).json(toScimUser(await user.reload()));
});
function toScimUser(user) {
return {
schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'],
id: user.id,
externalId: user.scimExternalId,
userName: user.email,
name: { givenName: user.firstName, familyName: user.lastName },
emails: [{ value: user.email, primary: true }],
active: user.active,
meta: {
resourceType: 'User',
created: user.createdAt,
lastModified: user.updatedAt,
location: `/scim/v2/Users/${user.id}`
}
};
}
The Groups endpoint
Groups in SCIM correspond to roles or teams in your application. The IdP pushes group membership; your app maps group names to permissions. This is the mechanism that enables "everyone in the Engineering Okta group gets admin access" without manual role assignment.
// POST /scim/v2/Groups — create a group and assign members
router.post('/Groups', async (req, res) => {
const { displayName, members = [] } = req.body;
const group = await db.groups.create({
tenantId: req.tenant.id,
name: displayName,
scimProvisioned: true
});
if (members.length) {
const userIds = members.map(m => m.value);
await db.groupMembers.bulkCreate(
userIds.map(userId => ({ groupId: group.id, userId }))
);
}
res.status(201).type(SCIM_CONTENT_TYPE).json(toScimGroup(group));
});
Okta SCIM setup
In Okta, navigate to Applications > your app > Provisioning tab. Enable "SCIM provisioning" and set:
- SCIM connector base URL:
https://yourapp.com/scim/v2 - Unique identifier for users:
userName - Authentication mode: HTTP Header (Bearer token — generate a long-lived token scoped only to SCIM operations)
- Enable: Push New Users, Push Profile Updates, Push Groups, Reactivate Users
After saving, Okta will call GET /scim/v2/Users?filter=userName+eq+"test@example.com" to verify connectivity. If your filter parsing is correct, you'll see a successful test.
Azure AD SCIM setup
In Entra ID (Azure AD), go to Enterprise Applications > your app > Provisioning. Set Provisioning Mode to Automatic. The Tenant URL is your SCIM base path (https://yourapp.com/scim/v2) and the Secret Token is a bearer token you generate. Click "Test Connection" — Azure sends a GET /scim/v2/Users call and expects a valid ListResponse.
schemas array than Okta's. Always return the exact schema URNs listed in RFC 7643. Missing or misspelled schema strings cause silent provisioning failures that are hard to debug.JIT provisioning vs SCIM: when each is right
Just-in-time (JIT) provisioning creates user accounts on first login from the IdP. It is simpler to implement — you hook into the SAML assertion or OIDC token processing and create the user if they don't exist. The problem is the off-boarding gap: when an employee is terminated, their account in your app persists until they next attempt to log in (and the IdP rejects them). In practice, for high-stakes applications this is a compliance failure.
SCIM solves the off-boarding gap. When HR terminates the employee in the IdP, Okta or Azure AD immediately sends a PATCH to set active: false (or a DELETE). The user's session tokens should be revoked at this point — which means your SCIM deactivation handler must also invalidate all active refresh tokens for that user.
The right answer for most SaaS applications targeting enterprise is both: SCIM for lifecycle management, SAML/OIDC SSO for authentication. SCIM handles provisioning and deprovisioning; SSO handles the actual authentication flow. They are complementary, not alternatives.
Implementation shortcuts to avoid
- Do not treat DELETE as soft-delete only. Some IdPs send DELETE for hard deprovisioning. Handle both: disable the account immediately, optionally queue for data deletion.
- Do not ignore the
externalIdfield. This is the IdP's internal identifier for the user. Store it and use it for lookup — IdPs may change the userName but never change the externalId. - The
filterquery parameter on list endpoints is not optional. Okta specifically usesfilter=userName eq "x"to check for existing users before creating them. If you return empty results incorrectly, Okta will create duplicates. - SCIM bearer tokens should be separate from your normal API tokens. They are long-lived and should be rotatable without affecting regular users. Store them with a
scim_tokentype in your token table.