.do
Enterprises

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

sequenceDiagram participant IdP as Customer IdP<br/>(Okta, Entra, etc.) participant WorkOS participant Webhook as Your Webhook participant App as Your App Note over IdP,App: Initial Sync IdP->>WorkOS: Full directory export (SCIM) WorkOS->>Webhook: dsync.activated event Webhook->>WorkOS: Fetch all users & groups Webhook->>App: Create user accounts Note over IdP,App: Ongoing Updates IdP->>WorkOS: User created (SCIM) WorkOS->>Webhook: dsync.user.created event Webhook->>App: Create user account IdP->>WorkOS: User updated (SCIM) WorkOS->>Webhook: dsync.user.updated event Webhook->>App: Update user profile IdP->>WorkOS: User deleted (SCIM) WorkOS->>Webhook: dsync.user.deleted event Webhook->>App: Disable user account

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:

  1. Select directory provider (Okta, Entra, Google, etc.)
  2. Configure SCIM endpoint and bearer token
  3. Test connection
  4. Activate directory sync
  5. 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 invalid

2. 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):

TierDirectoriesPrice per Directory/Month
1-1515 or fewer$125
16-3016-30$100
31-5031-50$80
51-10051-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/workos

Test 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_def