Directory Sync (SCIM)
Automate user provisioning and deprovisioning with WorkOS Directory Sync supporting SCIM 2.0, Azure AD, Okta, and Google Workspace
Directory Sync (SCIM)
Directory Sync enables automatic user and group provisioning from your customers' identity providers directly into your application. When employees join, leave, or change roles in the customer's organization, those changes are automatically reflected in your app.
Overview
Directory Sync provides:
- Automatic Provisioning - New employees automatically get accounts when added to the IdP
- Automatic Deprovisioning - Accounts are disabled/removed when employees leave
- Group Sync - Organizational structure and team membership synced automatically
- Attribute Sync - Profile data (name, email, department, etc.) stays up-to-date
- SCIM 2.0 Standard - Industry-standard protocol supported by major IdPs
- Real-Time Updates - Changes propagate within seconds via webhooks
Supported Directory Providers
WorkOS Directory Sync supports 15+ directory providers:
SCIM 2.0 Protocol
- Microsoft Entra ID (Azure AD)
- Okta
- OneLogin
- Ping Identity
- JumpCloud
- Rippling
- CyberArk
- Generic SCIM 2.0
Native APIs
- Google Workspace (G Suite)
- Microsoft 365
- Workday
- BambooHR
- Namely
- Hibob
- Fourth
How It Works
Setting Up Directory Sync
1. Create Directory Connection
import { WorkOS } from '@workos-inc/node'
const workos = new WorkOS(process.env.WORKOS_API_KEY)
// Customer's IT admin creates directory via Admin Portal
// Or programmatically:
const directory = await workos.directorySync.getDirectory('directory_01HXYZ...')
console.log(directory)
// {
// id: 'directory_01HXYZ...',
// organizationId: 'org_01ABC...',
// name: 'Acme Corp Okta Directory',
// type: 'okta scim v2.0',
// state: 'linked',
// domains: [
// { domain: 'acme.com' }
// ]
// }2. Handle Directory Events
WorkOS sends webhook events for all directory changes:
import { WorkOS } from '@workos-inc/node'
const workos = new WorkOS(process.env.WORKOS_API_KEY)
// Webhook handler
on($.Webhook.received, async ({ payload, headers }) => {
// Verify webhook signature
const event = workos.webhooks.constructEvent({
payload,
sigHeader: headers['workos-signature'],
secret: process.env.WORKOS_WEBHOOK_SECRET
})
switch (event.event) {
case 'dsync.activated':
await handleDirectoryActivated(event.data)
break
case 'dsync.user.created':
await handleUserCreated(event.data)
break
case 'dsync.user.updated':
await handleUserUpdated(event.data)
break
case 'dsync.user.deleted':
await handleUserDeleted(event.data)
break
case 'dsync.group.created':
await handleGroupCreated(event.data)
break
case 'dsync.group.updated':
await handleGroupUpdated(event.data)
break
case 'dsync.group.deleted':
await handleGroupDeleted(event.data)
break
case 'dsync.group.user_added':
await handleGroupUserAdded(event.data)
break
case 'dsync.group.user_removed':
await handleGroupUserRemoved(event.data)
break
}
})3. Implement Event Handlers
User Created
async function handleUserCreated({ directory_id, directory_user_id }) {
// Fetch full user data from WorkOS
const directoryUser = await workos.directorySync.getUser(directory_user_id)
// Create user in your app
const user = await $.User.create({
email: directoryUser.emails[0].value,
firstName: directoryUser.firstName,
lastName: directoryUser.lastName,
username: directoryUser.username,
state: directoryUser.state, // 'active' or 'inactive'
organizationId: directoryUser.organizationId,
idpId: directoryUser.idpId,
directoryId: directory_id,
directoryUserId: directory_user_id,
rawAttributes: directoryUser.rawAttributes
})
// Send welcome email if active
if (user.state === 'active') {
await send($.Email.send, {
to: user.email,
template: 'welcome-provisioned',
data: { user }
})
}
return user
}User Updated
async function handleUserUpdated({ directory_id, directory_user_id }) {
const directoryUser = await workos.directorySync.getUser(directory_user_id)
// Find existing user
const user = await db.find($.User, { directoryUserId: directory_user_id })
if (!user) {
// User doesn't exist yet, create them
return await handleUserCreated({ directory_id, directory_user_id })
}
// Update user profile
await $.User.update(user.id, {
email: directoryUser.emails[0].value,
firstName: directoryUser.firstName,
lastName: directoryUser.lastName,
state: directoryUser.state,
rawAttributes: directoryUser.rawAttributes
})
// Handle state changes
if (user.state === 'active' && directoryUser.state === 'inactive') {
await $.User.disable(user.id)
await send($.Event.log, {
type: 'user.deactivated',
userId: user.id,
reason: 'directory_sync'
})
} else if (user.state === 'inactive' && directoryUser.state === 'active') {
await $.User.enable(user.id)
await send($.Event.log, {
type: 'user.reactivated',
userId: user.id,
reason: 'directory_sync'
})
}
}User Deleted
async function handleUserDeleted({ directory_id, directory_user_id }) {
const user = await db.find($.User, { directoryUserId: directory_user_id })
if (!user) return
// Option 1: Soft delete (recommended)
await $.User.update(user.id, {
state: 'inactive',
deletedAt: new Date(),
deletionReason: 'directory_sync'
})
// Revoke all sessions
await db.delete($.Session, { userId: user.id })
// Remove from groups
await db.delete($.GroupMember, { userId: user.id })
// Log deprovisioning event
await send($.Event.log, {
type: 'user.deprovisioned',
userId: user.id,
metadata: { directory_id, directory_user_id }
})
// Option 2: Hard delete (use with caution)
// await $.User.delete(user.id)
}Group Management
async function handleGroupCreated({ directory_id, directory_group_id }) {
const directoryGroup = await workos.directorySync.getGroup(directory_group_id)
// Create group/team in your app
const group = await $.Group.create({
name: directoryGroup.name,
organizationId: directoryGroup.organizationId,
directoryId: directory_id,
directoryGroupId: directory_group_id,
idpId: directoryGroup.idpId,
rawAttributes: directoryGroup.rawAttributes
})
return group
}
async function handleGroupUserAdded({ directory_id, directory_group_id, directory_user_id }) {
const group = await db.find($.Group, { directoryGroupId: directory_group_id })
const user = await db.find($.User, { directoryUserId: directory_user_id })
if (!group || !user) return
// Add user to group
await $.Group.addMember({
groupId: group.id,
userId: user.id,
source: 'directory_sync'
})
// Potentially assign role based on group
const roleMapping = {
'Engineering': 'developer',
'Sales': 'sales',
'Administrators': 'admin'
}
const role = roleMapping[group.name]
if (role) {
await $.User.assign.Role({
userId: user.id,
roleId: role
})
}
}
async function handleGroupUserRemoved({ directory_id, directory_group_id, directory_user_id }) {
const group = await db.find($.Group, { directoryGroupId: directory_group_id })
const user = await db.find($.User, { directoryUserId: directory_user_id })
if (!group || !user) return
// Remove user from group
await $.Group.removeMember({
groupId: group.id,
userId: user.id
})
}4. Initial Directory Sync
When a directory is first activated, sync all existing users:
async function handleDirectoryActivated({ directory_id }) {
const directory = await workos.directorySync.getDirectory(directory_id)
// Fetch all users (paginated)
let after = null
let hasMore = true
while (hasMore) {
const response = await workos.directorySync.listUsers({
directory: directory_id,
limit: 100,
after
})
// Create users in batch
for (const directoryUser of response.data) {
await handleUserCreated({
directory_id,
directory_user_id: directoryUser.id
})
}
after = response.listMetadata.after
hasMore = !!after
}
// Fetch all groups
after = null
hasMore = true
while (hasMore) {
const response = await workos.directorySync.listGroups({
directory: directory_id,
limit: 100,
after
})
for (const directoryGroup of response.data) {
await handleGroupCreated({
directory_id,
directory_group_id: directoryGroup.id
})
}
after = response.listMetadata.after
hasMore = !!after
}
// Mark directory as synced
await db.update($.Directory, directory_id, {
lastSyncedAt: new Date(),
syncStatus: 'active'
})
}User Data Model
Directory users include rich profile data:
{
id: 'directory_user_01HXYZ...',
idpId: 'okta-user-id-12345',
directoryId: 'directory_01ABC...',
organizationId: 'org_01DEF...',
state: 'active', // or 'inactive'
emails: [
{
primary: true,
type: 'work',
value: '[email protected]'
}
],
firstName: 'Jane',
lastName: 'Doe',
username: 'jane.doe',
groups: [
{
id: 'directory_group_01XYZ...',
name: 'Engineering',
idpId: 'okta-group-id'
}
],
rawAttributes: {
// Provider-specific attributes
department: 'Engineering',
jobTitle: 'Senior Engineer',
employeeId: 'EMP-12345',
manager: '[email protected]',
location: 'San Francisco'
}
}Custom Attribute Mapping
Map IdP-specific attributes to your data model:
async function mapDirectoryUser(directoryUser) {
const raw = directoryUser.rawAttributes
return {
// Standard fields
email: directoryUser.emails[0].value,
firstName: directoryUser.firstName,
lastName: directoryUser.lastName,
// Custom mappings
department: raw.department || raw['urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department'],
jobTitle: raw.title || raw.jobTitle,
employeeId: raw.employeeNumber || raw.employeeId,
managerId: raw.manager?.value || raw.manager,
officeLocation: raw.location || raw.physicalDeliveryOfficeName,
phoneNumber: raw.phoneNumbers?.[0]?.value || raw.telephoneNumber,
// Okta-specific
division: raw['urn:okta:custom:user:1.0:division'],
// Azure AD-specific
costCenter: raw['urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:costCenter'],
// Google Workspace-specific
orgUnitPath: raw.orgUnitPath
}
}Role-Based Access Control (RBAC)
Automatically assign roles based on groups:
const groupRoleMapping = {
'Engineering': ['developer', 'api-access'],
'Engineering - Senior': ['developer', 'api-access', 'code-review'],
'Product Management': ['product-manager', 'analytics-access'],
'Sales': ['sales', 'crm-access'],
'Support': ['support', 'ticket-access'],
'Administrators': ['admin', 'all-access']
}
on($.Directory.group.user_added, async ({ group, user }) => {
const roles = groupRoleMapping[group.name] || []
for (const roleId of roles) {
await $.User.assign.Role({ userId: user.id, roleId })
}
})
on($.Directory.group.user_removed, async ({ group, user }) => {
const roles = groupRoleMapping[group.name] || []
for (const roleId of roles) {
await $.User.unassign.Role({ userId: user.id, roleId })
}
})Admin Portal Configuration
IT admins configure Directory Sync via the WorkOS Admin Portal:
// Generate Admin Portal link
const { link } = await workos.portal.generateLink({
organization: 'org_01ABC...',
intent: 'dsync', // Directory Sync configuration
returnUrl: 'https://platform.do/settings/directory'
})
// Send to customer's IT admin
await send($.Email.send, {
to: '[email protected]',
template: 'directory-sync-setup',
data: { portalLink: link }
})In the portal, admins can:
- Select directory provider (Okta, Entra, Google, etc.)
- Configure SCIM endpoint and bearer token
- Test connection
- Activate directory sync
- Monitor sync status and errors
Monitoring & Analytics
Track directory sync health and usage:
// Get directory sync statistics
const stats = await db.query(`
SELECT
directory_id,
COUNT(*) FILTER (WHERE event_type = 'dsync.user.created') as users_created,
COUNT(*) FILTER (WHERE event_type = 'dsync.user.updated') as users_updated,
COUNT(*) FILTER (WHERE event_type = 'dsync.user.deleted') as users_deleted,
COUNT(*) FILTER (WHERE event_type = 'dsync.group.created') as groups_created
FROM events
WHERE event_type LIKE 'dsync.%'
AND timestamp > NOW() - INTERVAL '30 days'
GROUP BY directory_id
`)
// Check sync health
const directory = await workos.directorySync.getDirectory(directoryId)
console.log({
state: directory.state, // 'linked', 'unlinked', 'invalid_credentials'
lastSyncedAt: directory.lastSyncedAt,
userCount: await db.count($.User, { directoryId }),
groupCount: await db.count($.Group, { directoryId })
})Error Handling
Handle common directory sync errors:
on($.Webhook.received, async ({ payload }) => {
try {
const event = workos.webhooks.constructEvent(payload)
await handleDirectoryEvent(event)
} catch (error) {
if (error.code === 'invalid_credentials') {
// Notify customer that SCIM credentials are invalid
await send($.Email.send, {
to: '[email protected]',
template: 'directory-sync-credentials-invalid',
data: { directoryId: event.data.directory_id }
})
} else if (error.code === 'directory_not_found') {
// Directory was deleted in WorkOS
await db.update($.Directory, event.data.directory_id, {
state: 'unlinked'
})
} else {
// Log unexpected error
await send($.Event.log, {
type: 'dsync.error',
severity: 'error',
metadata: { error: error.message, event }
})
}
}
})Security Considerations
1. Webhook Verification
Always verify webhook signatures:
const event = workos.webhooks.constructEvent({
payload: request.body,
sigHeader: request.headers['workos-signature'],
secret: process.env.WORKOS_WEBHOOK_SECRET
})
// Throws error if signature is invalid2. Soft Deletion
Prefer soft deletion over hard deletion to maintain audit trails:
// Good: Soft delete
await $.User.update(userId, {
state: 'inactive',
deletedAt: new Date()
})
// Use with caution: Hard delete
// await $.User.delete(userId)3. Data Retention
Comply with data retention policies:
// Permanently delete after 90 days
on($.User.deleted, async ({ user }) => {
await scheduleTask({
runAt: addDays(new Date(), 90),
task: 'hard-delete-user',
data: { userId: user.id }
})
})Pricing
WorkOS Directory Sync pricing (as of 2025):
| Tier | Directories | Price per Directory/Month |
|---|---|---|
| 1-15 | 15 or fewer | $125 |
| 16-30 | 16-30 | $100 |
| 31-50 | 31-50 | $80 |
| 51-100 | 51-100 | $65 |
| 101+ | 101+ | Custom pricing |
Note: Bundled with SSO - no additional cost if already using SSO.
Testing Directory Sync
Local Development
Use WorkOS webhooks CLI for local testing:
# Install WorkOS CLI
npm install -g @workos-inc/workos-cli
# Forward webhooks to localhost
workos webhooks forward --port 3000 --path /webhooks/workosTest Events
Send test events via WorkOS Dashboard:
# Or via API
curl -X POST https://api.workos.com/events \
-H "Authorization: Bearer $WORKOS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"event": "dsync.user.created",
"data": {
"directory_id": "directory_test_123",
"directory_user_id": "directory_user_test_456"
}
}'Migration Strategies
Migrating Existing Users
Link existing users to directory users:
// Option 1: Match by email
const directoryUser = await workos.directorySync.getUser(directoryUserId)
const existingUser = await db.find($.User, { email: directoryUser.emails[0].value })
if (existingUser) {
await $.User.update(existingUser.id, {
directoryId: directory.id,
directoryUserId: directoryUser.id,
idpId: directoryUser.idpId
})
}
// Option 2: Import mapping from CSV
// user_id,directory_user_id
// user_123,directory_user_abc
// user_456,directory_user_defRelated Documentation
- Enterprise SSO - Single Sign-On authentication
- Organizations - Multi-tenant organization management
- Admin Portal - Self-service configuration
- Audit Logs - Compliance and security monitoring
- WorkOS Directory Sync Docs - Official WorkOS documentation