# Migrate from WorkOS (/docs/guides/migration/from-workos)

> Move organizations, verified domains, SSO connections, Directory Sync, and AuthKit users and memberships from WorkOS to Better IAM: tenants per organization, recreated SAML, OIDC, and SCIM connections, an import script, and a per-organization cut-over.



This guide moves the enterprise features of an application from WorkOS to Better IAM: each customer organization,
its verified domains, its SSO connection, its directory, and, if you use AuthKit, its users and memberships. WorkOS
keeps serving every organization until you switch it, and switching one customer is a change their IT team makes in
their identity provider. You will:

* export organizations, domains, connection and directory details, and AuthKit users and memberships;
* create a Better IAM tenant per organization and re-verify its domains;
* recreate each SAML or OIDC connection and each SCIM directory, with the customer's IT team registering the new
  values next to the old ones;
* import AuthKit users with their roles, verified email status, and links to their SSO and social accounts, and
  leave people a SCIM directory manages to that directory;
* replace WorkOS calls in your sign-in flow, then cut over one organization at a time.

The general plan behind these steps is in [Migration guides](/docs/guides/migration).

## How WorkOS concepts map [#how-workos-concepts-map]

| WorkOS                                                                       | Better IAM                                                                                        | Notes                                                                                                                                                                |
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Environment (staging, production)                                            | One deployment per environment                                                                    |                                                                                                                                                                      |
| Organization                                                                 | Tenant (type `organization`)                                             | Keep the WorkOS organization ID in your own mapping table; tenants carry no custom attributes.                                                                       |
| Organization domain                                                          | `domains.add` and `domains.verify`                                                                | A new DNS TXT record per domain. `domains.discover` maps an email address to its organization.                                                                       |
| SSO connection (SAML)                                                        | SAML connection, `saml.createConnection`                                                          | Created at runtime per tenant from the IdP's metadata.                                                                                                               |
| SSO connection (OIDC)                                                        | `createOAuthLogin` connection, `kind: 'oidc'` or `'microsoft'`                                    | Fixed in the deployment configuration; each connection serves one tenant.                                                                                            |
| SSO profile                                                                  | The identity and its external-identity link                                                       | The profile's `idp_id` becomes the link's subject at import; for people not imported, the link is created at their first SSO sign-in. Profile IDs do not carry over. |
| Directory (SCIM)                                                             | SCIM connection, `scim.createConnection`                                                          | The customer replaces the SCIM URL and token in their IdP.                                                                                                           |
| Directory read through an API (for example Google Workspace or an HR system) | No equivalent                                                                                     | Better IAM receives SCIM 2.0 only. Keep people current with your own job calling `identities.createMany` and `identities.setStatus`, or rely on SSO sign-in.         |
| Directory user                                                               | Identity created by SCIM                                               |                                                                                                                                                                      |
| Directory group                                                              | Group created by SCIM                                                     | `scim.setRoleMappings` gives a directory group roles.                                                                                                                |
| Admin Portal                                                                 | Your own settings screens (or the reference console)                                              | Customer administrators call `saml.createConnection`, `scim.createConnection`, and `domains.add` through your UI; each checks their permissions in their own tenant. |
| AuthKit user                                                                 | Identity                                                                                          | One per organization the user belongs to.                                                                                                                            |
| Organization membership                                                      | An identity in that tenant                                                                        | A person in two organizations gets two identities with the same email ([account linking](/docs/concepts/tenants-and-identities#account-linking) connects them).      |
| Role and permissions                                                         | Role per tenant, with actions                     | Declare actions in `permissions.resourceTypes`.                                                                                                                      |
| Magic Auth                                                                   | [Emailed codes](/docs/authentication/passwordless) (`auth.startPasswordless` with `kind: 'code'`) |                                                                                                                                                                      |
| Sessions and access tokens                                                   | [Sessions](/docs/authentication/sessions) and [session tokens](/docs/authentication/tokens)       |                                                                                                                                                                      |
| Impersonation                                                                | [Impersonation](/docs/authentication/impersonation)                                               |                                                                                                                                                                      |
| Events and webhooks                                                          | [Webhooks](/docs/concepts/events/webhooks)                                                        |                                                                                                                                                                      |
| Audit Logs (events your application emits)                                   | No equivalent                                                                                     | The Better IAM audit log records Better IAM's own operations; there is no API to append your application's events.                                                   |
| Fine-Grained Authorization                                                   | [Relationships](/docs/authorization/relationships)                                                | Relations declared per resource type, tuples written with `relationships.create`.                                                                                    |

> **What does not move.** 
  Password hashes (no Better IAM API accepts one), MFA enrollments, passkeys, and sessions. Links to SSO and social
  accounts are recreated at import from each person's `idp_id`. SSO and SCIM connections are recreated, and each
  customer's IT team must register the new values in their identity provider; WorkOS's endpoints cannot be pointed at
  Better IAM from your side.

## Prerequisites [#prerequisites]

* A Better IAM deployment with a migrated database and a bootstrapped root administrator
  ([Quickstart](/docs/quickstart)), configured with `authentication.sendEmail`.
* Read access to the WorkOS API for the environment you are leaving.
* For each enterprise customer, a contact on their IT team who can add an application in their identity provider
  and change its provisioning settings.
* A public HTTPS origin for your app, because identity providers post to it.
* A root administrator session for the scripts:

```bash
npx better-iam login --config better-iam.config.mjs --tenant "$ROOT_TENANT_ID" --email root@example.com
export BETTER_IAM_TOKEN=$(npx better-iam token --config better-iam.config.mjs)
```

      ### Export from WorkOS [#export-from-workos]

    List organizations, their domains, their SSO connections and directories, and (with AuthKit) users and
    organization memberships through the WorkOS API, and save them in this shape:

    ```json title="workos/organizations.json"
    [
      {
        "id": "org_01H...",
        "name": "Acme",
        "domains": ["acme.com"],
        "ownerEmail": "it-admin@acme.com",
        "sso": "saml",
        "directory": "scim"
      }
    ]
    ```

    | Field        | Meaning                                                                                                         |
    | ------------ | --------------------------------------------------------------------------------------------------------------- |
    | `ownerEmail` | Who will own the tenant in Better IAM. Their account is created with the tenant.                                |
    | `sso`        | `saml`, `oidc`, or `null`: the organization's active connection type.                                           |
    | `directory`  | `scim` when the customer's IdP pushes to WorkOS, `api` when WorkOS reads a directory through an API, or `null`. |

    With AuthKit, also write `workos/users.ndjson` and `workos/memberships.json` (`[{ organizationId, userId, roleSlug }]`).
    Each user line holds `{ id, email, email_verified, first_name, last_name, identities }`, where `identities` lists the
    person's external accounts as `{ provider, idp_id }`: AuthKit's identities for the user (`provider` such as
    `GoogleOAuth`), plus `{ provider: 'SSO', idp_id }` from the person's SSO profile, whose `idp_id` is their ID at the
    customer's IdP (the NameID for SAML, `sub` for OIDC). If your own database holds your users and WorkOS only handles
    SSO and directories, export them from that database in the same shape instead, with the `idp_id` of the SSO
    profile your application received at sign-in.

    For each SAML connection, get the customer's IdP metadata file (or its sign-on URL, entity ID, and signing
    certificate) from the connection's details or from the customer's IT team.
  
      ### Configure Better IAM and mount the protocols [#configure-better-iam-and-mount-the-protocols]

    ```ts title="iam.ts"
    import { betterIam } from 'better-iam';
    import { postgresAdapter } from 'better-iam/adapter-postgres';
    import { sendEmail } from './mail';

    export const iam = betterIam({
      database: postgresAdapter({ connectionString: process.env.DATABASE_URL! }),
      secret: process.env.BETTER_IAM_SECRET!,
      baseURL: process.env.BETTER_IAM_BASE_URL!, // https://app.example.com
      authentication: {
        sendEmail,
        passwordlessEmail: true, // emailed codes replace Magic Auth
      },
      permissions: {
        identityAttributes: { workosId: 'string', department: 'string' },
        resourceTypes: {
          project: { actions: ['projects:read', 'projects:write'] },
        },
      },
    });
    ```

    ```ts title="protocols.ts"
    import { readFileSync } from 'node:fs';
    import { createSamlService } from 'better-iam/saml';
    import { createScimService } from 'better-iam/scim';
    import { iam } from './iam';

    export const saml = createSamlService({
      ...iam.protocolHost,
      basePath: '/api/iam/saml',
      serviceProvider: {
        baseUrl: process.env.BETTER_IAM_BASE_URL!,
        privateKey: readFileSync(process.env.SAML_SP_KEY_FILE!, 'utf8'),
        publicCertificate: readFileSync(process.env.SAML_SP_CERT_FILE!, 'utf8'),
      },
      afterSignIn: '/dashboard', // the ACS redirects here with the session cookie set
      errorUrl: '/login', // failed sign-ins land here with ?error=CODE
    });

    export const scim = createScimService({
      ...iam.protocolHost,
      basePath: '/api/iam/scim/v2',
      adminBasePath: '/api/iam/scim-admin',
      mapAttributes: ({ enterprise }) =>
        typeof enterprise?.department === 'string' ? { department: enterprise.department } : undefined,
    });

    iam.useProtocol(saml);
    iam.useProtocol(scim);
    ```

    With `afterSignIn`, the SAML ACS answers a completed sign-in with a redirect to that page instead of JSON;
    `?returnTo=/path` on a connection's `loginUrl` picks the page for one sign-in, and an optional `mfaUrl` receives
    people who still have to pass MFA. Route every method (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`) under `/api/iam` to
    `iam.handler`, so identity providers reach the SAML and SCIM endpoints ([Protocol mounts](/docs/operations/deployment/protocol-mounts)). If you
    add OIDC connections, route `/oauth/login/*` and each connection's `redirectUri` path to `iam.handler` as well. Then
    migrate and take a snapshot:

    ```bash
    npx better-iam migrate --config better-iam.config.mjs
    npx better-iam store-export --config better-iam.config.mjs --output before-workos-import.jsonl
    ```
  
      ### Create tenants and claim domains [#create-tenants-and-claim-domains]

    ```ts title="scripts/workos-create-tenants.ts"
    import { readFile, writeFile } from 'node:fs/promises';
    import { iam } from '../iam';

    type WorkosOrganization = {
      id: string;
      name: string;
      domains: string[];
      ownerEmail: string;
      sso: 'saml' | 'oidc' | null;
      directory: 'scim' | 'api' | null;
    };

    const credential = { token: process.env.BETTER_IAM_TOKEN! };
    const rootTenantId = process.env.ROOT_TENANT_ID!;
    const organizations = JSON.parse(await readFile('workos/organizations.json', 'utf8')) as WorkosOrganization[];

    const tenantMap: Record<string, string> = {};
    for (const organization of organizations) {
      const { tenant } = await iam.api.tenants.create(credential, {
        parentId: rootTenantId,
        type: 'organization',
        name: organization.name,
        ownerEmail: organization.ownerEmail,
        activate: true, // active now; the owner's account is created without an invitation
      });
      tenantMap[organization.id] = tenant.id;
    }
    await writeFile('workos/tenant-map.json', JSON.stringify(tenantMap, null, 2));
    ```

    With `activate: true`, which only a root principal may pass, each tenant is `active` at once and its owner's account
    exists, so you can claim domains and import people right away. No invitation is sent: the owner signs in with an
    emailed code, a reset link, or their SSO account once you link it (see the import step). Without `activate`, the
    tenant stays `pending` until its owner accepts an emailed invitation. Claim each tenant's domains and send the
    customer the TXT records to publish:

    ```ts title="scripts/workos-claim-domains.ts"
    import { readFile } from 'node:fs/promises';
    import { iam } from '../iam';

    type WorkosOrganization = { id: string; name: string; domains: string[] };

    const credential = { token: process.env.BETTER_IAM_TOKEN! };
    const organizations = JSON.parse(await readFile('workos/organizations.json', 'utf8')) as WorkosOrganization[];
    const tenantMap = JSON.parse(await readFile('workos/tenant-map.json', 'utf8')) as Record<string, string>;

    for (const organization of organizations) {
      const tenantId = tenantMap[organization.id]!;
      const claimed = await iam.api.domains.list(credential, { tenantId });
      for (const domain of organization.domains) {
        const existing = claimed.find((item) => item.domain === domain);
        const claim = existing ?? (await iam.api.domains.add(credential, { tenantId, domain }));
        if (claim.status === 'verified') continue;
        const { verified } = await iam.api.domains.verify(credential, { tenantId, domainId: claim.id });
        if (!verified) {
          console.log(`${organization.name}: publish ${claim.dnsRecord.type} ${claim.dnsRecord.name} = ${claim.dnsRecord.value}`);
        }
      }
    }
    ```

    Run it again after customers publish their records; `domains.verify` marks each one verified when the record
    matches. A domain verified by another tenant fails with `DOMAIN_TAKEN`.
  
      ### Recreate SSO connections [#recreate-sso-connections]

    Create each customer's connection in their tenant. Their IT team then adds a **new** application in their identity
    provider with the values Better IAM returns, next to the one that points at WorkOS, so both work until you switch
    them.

          **SAML:**

        ```ts title="scripts/workos-saml-acme.ts"
        import { readFile } from 'node:fs/promises';
        import { saml } from '../protocols';

        const credential = { token: process.env.BETTER_IAM_TOKEN! };

        const connection = await saml.createConnection(credential, {
          tenantId: process.env.ACME_TENANT_ID!,
          id: 'acme-okta',
          name: 'Acme Okta',
          metadataXml: await readFile('workos/acme-idp-metadata.xml', 'utf8'),
          trustedEmailDomains: ['acme.com'],
          attributeMapping: { department: 'department' },
        });
        console.log({ entityId: connection.entityId, acsUrl: connection.acsUrl, loginUrl: connection.loginUrl });
        ```

        The customer sets the new application's audience (SP entity ID) to `entityId` and its ACS URL to `acsUrl`, signs
        both the response and the assertion, and sends an `email` attribute. `trustedEmailDomains` lets the first sign-in
        of a person who was not imported create their account. The import step links imported people to this connection
        with its `providerId` and `idpIssuer`. Store `loginUrl` with your organization record; sign-in starts there. See
        [SAML](/docs/federation/saml#register-the-app-at-the-idp).
      
      **OIDC:**

        ```ts title="sso.ts"
        import { createOAuthLogin } from 'better-iam/oauth';
        import { iam } from './iam';

        export const login = createOAuthLogin({
          ...iam.protocolHost,
          trustedOrigins: ['https://app.example.com'],
          connections: [
            {
              id: 'globex-entra',
              tenantId: process.env.GLOBEX_TENANT_ID!,
              kind: 'microsoft',
              microsoftTenant: process.env.GLOBEX_ENTRA_TENANT_ID!,
              clientId: process.env.GLOBEX_CLIENT_ID!,
              clientSecret: process.env.GLOBEX_CLIENT_SECRET!,
              redirectUri: 'https://app.example.com/oauth/globex-entra/callback',
            },
          ],
        });
        iam.useProtocol(login);
        ```

        The customer adds `redirectUri` to their app registration (or registers a new one) and gives you its client ID and
        secret. Use `kind: 'oidc'` with the provider's `issuer` for any other OpenID Connect provider. See
        [OAuth and OIDC sign-in](/docs/federation/oauth-sign-in).
            
      ### Re-point SCIM directories [#re-point-scim-directories]

    For each organization whose directory pushes through SCIM, create a connection and send the customer its URL and
    token:

    ```ts title="scripts/workos-scim-acme.ts"
    import { saml, scim } from '../protocols';

    const credential = { token: process.env.BETTER_IAM_TOKEN! };

    const connection = await scim.createConnection(credential, {
      tenantId: process.env.ACME_TENANT_ID!,
      name: 'Acme Okta provisioning',
    });
    console.log('SCIM base URL:', `${process.env.BETTER_IAM_BASE_URL}${connection.path}`);
    console.log('Bearer token (shown once):', connection.token);

    // People this directory creates link to the SSO connection at their first SSO sign-in.
    await saml.updateConnection(credential, {
      tenantId: process.env.ACME_TENANT_ID!,
      connectionId: 'acme-okta',
      trustScimProvisioned: [connection.id],
    });
    ```

    In the provisioning settings of the new application, the customer enters this URL and token and assigns the same
    people and groups as in the WorkOS one. The directory then creates the people and groups in Better IAM. After the
    groups arrive, map them to roles:

    ```ts title="scripts/workos-scim-roles.ts"
    import { scim } from '../protocols';

    const credential = { token: process.env.BETTER_IAM_TOKEN! };

    await scim.setRoleMappings(credential, {
      tenantId: process.env.ACME_TENANT_ID!,
      connectionId: process.env.ACME_SCIM_CONNECTION_ID!,
      groupId: process.env.ACME_ENGINEERING_GROUP_ID!, // the group the directory pushed
      roleIds: [process.env.ACME_MEMBER_ROLE_ID!],
    });
    ```

    With `trustScimProvisioned` on the SSO connection, a person the directory created signs in through SSO the first
    time with no other step: their verified email (a domain in `trustedEmailDomains`) links the SSO account to the
    directory's account. Updating the connection's trust needs a sign-in from the last five minutes. For an OIDC
    connection, set `trustScimProvisioned` (`true`, or a list of SCIM connection IDs) on its entry in
    `createOAuthLogin`. Directories that WorkOS read through an API have no SCIM endpoint to re-point; import those
    organizations' people with links to their SSO connection (next step), and keep them current with
    `identities.createMany` and `identities.setStatus` from your own job.
  
      ### Import AuthKit users [#import-authkit-users]

    Import everyone except members of organizations with a SCIM directory: the directory creates those people, and
    importing them would make its create fail with a `409 uniqueness` error. Each imported person is linked to their
    organization's SSO connection (and to any social connection you recreate) by their `idp_id`, so their first sign-in
    through it finds their imported account, with its role.

    ```ts title="scripts/workos-import-users.ts"
    import { createReadStream } from 'node:fs';
    import { readFile, writeFile } from 'node:fs/promises';
    import { createInterface } from 'node:readline';
    import { iam } from '../iam';
    import { saml } from '../protocols';

    type WorkosOrganization = { id: string; sso: string | null; directory: string | null };
    type WorkosUser = {
      id: string;
      email: string;
      email_verified?: boolean;
      first_name: string | null;
      last_name: string | null;
      identities?: Array<{ provider: string; idp_id: string }>;
    };
    type Membership = { organizationId: string; userId: string; roleSlug: string };
    type Link = { providerId: string; issuer: string };

    // WorkOS role slug -> Better IAM role name in every tenant.
    const roleNames: Record<string, string> = { admin: 'Admin', member: 'Member' };

    // WorkOS identity provider -> the Better IAM connection that replaces it, per WorkOS organization. 'SSO' is the
    // organization's SSO connection; SAML connections are read from the tenant below, OIDC ones are listed here with
    // their id and exact issuer.
    const CONNECTIONS: Record<string, Record<string, Link>> = {
      org_globex: {
        SSO: {
          providerId: 'globex-entra',
          issuer: `https://login.microsoftonline.com/${process.env.GLOBEX_ENTRA_TENANT_ID}/v2.0`,
        },
      },
    };

    const credential = { token: process.env.BETTER_IAM_TOKEN! };
    const organizations = JSON.parse(await readFile('workos/organizations.json', 'utf8')) as WorkosOrganization[];
    const tenantMap = JSON.parse(await readFile('workos/tenant-map.json', 'utf8')) as Record<string, string>;
    const memberships = JSON.parse(await readFile('workos/memberships.json', 'utf8')) as Membership[];

    const users = new Map<string, WorkosUser>();
    for await (const line of createInterface({ input: createReadStream('workos/users.ndjson'), crlfDelay: Infinity })) {
      if (line.trim()) {
        const user = JSON.parse(line) as WorkosUser;
        users.set(user.id, user);
      }
    }

    async function existingEmails(tenantId: string): Promise<Set<string>> {
      const emails = new Set<string>();
      for (let offset = 0; ; offset += 1000) {
        const page = await iam.api.identities.list(credential, { tenantId, kind: 'user', limit: 1000, offset });
        for (const identity of page) if (identity.email) emails.add(identity.email);
        if (page.length < 1000) return emails;
      }
    }

    for (const organization of organizations) {
      if (organization.directory === 'scim') continue; // the directory creates these people
      const tenantId = tenantMap[organization.id]!;
      const targets: Record<string, Link> = { ...CONNECTIONS[organization.id] };
      if (organization.sso === 'saml') {
        const [connection] = await saml.listConnections(credential, { tenantId });
        if (connection) targets.SSO = { providerId: connection.providerId, issuer: connection.idpIssuer };
      }
      if (organization.sso && !targets.SSO) {
        console.log(`${organization.id}: recreate its SSO connection first`);
        continue;
      }

      await iam.api.config.apply(credential, {
        tenantId,
        config: {
          version: 1,
          roles: [
            { name: 'Admin', permissions: ['projects:read', 'projects:write'] },
            { name: 'Member', permissions: ['projects:read'] },
          ],
        },
      });
      const roleIds = new Map((await iam.api.roles.list(credential, { tenantId })).map((role) => [role.name, role.id]));
      const seen = await existingEmails(tenantId);

      const rows = memberships
        .filter((membership) => membership.organizationId === organization.id)
        .flatMap((membership) => {
          const user = users.get(membership.userId);
          const email = user?.email.trim().toLowerCase();
          if (!user || !email || seen.has(email)) return [];
          seen.add(email);
          const roleId = roleIds.get(roleNames[membership.roleSlug] ?? '');
          const providers = new Map<string, Link & { subject: string }>();
          for (const identity of user.identities ?? []) {
            const target = targets[identity.provider];
            if (target) providers.set(target.providerId, { ...target, subject: identity.idp_id });
          }
          return [{
            workosId: user.id,
            entry: {
              email,
              name: [user.first_name, user.last_name].filter(Boolean).join(' ') || email,
              emailVerified: user.email_verified === true,
              providers: [...providers.values()],
              attributes: { workosId: user.id },
              roleIds: roleId ? [roleId] : [],
            },
          }];
        });

      const idMap: Record<string, string> = {};
      for (let start = 0; start < rows.length; start += 100) {
        const batch = rows.slice(start, start + 100);
        const { identities } = await iam.api.identities.createMany(credential, {
          tenantId,
          identities: batch.map((row) => row.entry),
        });
        batch.forEach((row, i) => (idMap[row.workosId] = identities[i]!.id));
      }
      await writeFile(`workos/id-map-${organization.id}.json`, JSON.stringify(idMap, null, 2));
      console.log(`${organization.id}: imported ${rows.length}`);
    }
    ```

    ```bash
    npx tsx scripts/workos-import-users.ts
    ```

    Imported people have no password. Their email is verified where WorkOS says `email_verified: true`; the others
    verify it with their first emailed code or magic link. People with an SSO link sign in through their company's IdP
    as before. The script skips addresses that already exist, so run it again during the dual run to pick up new
    sign-ups.

    * Add an entry under an organization in `CONNECTIONS` for each social connection you recreate in its tenant, for
      example `GoogleOAuth: { providerId: 'acme-google', issuer: 'https://accounts.google.com' }`. Identities without an
      entry get no link.
    * The owner already exists, so the script skips them. Link their SSO account with
      `iam.api.identities.linkProvider(credential, { tenantId, identityId, providerId, issuer, subject })`, which needs a
      sign-in from the last five minutes.
    * An external account listed under two users of the same organization fails the batch with
      [`ACCOUNT_LINK_CONFLICT`](/docs/reference/errors#account_link_conflict).
  
      ### Replace WorkOS in your sign-in flow [#replace-workos-in-your-sign-in-flow]

    WorkOS apps usually find a person's organization from their email domain and send them to its SSO connection.
    `domains.discover` (public, no credential) does the first half; your organization record supplies the connection's
    `loginUrl` you stored.

    ```ts title="app/api/sign-in/route.ts"
    import { iam } from '@/lib/iam';
    import { findOrganizationByTenantId } from '@/lib/organizations'; // your table: tenantId -> { loginUrl? }

    export async function POST(request: Request) {
      const { email } = (await request.json()) as { email: string };
      // A domain no tenant has verified is refused; treat it as "no organization".
      const found = await iam.api.domains.discover({ email }).catch(() => null);
      if (!found) return Response.json({ method: 'passwordless', tenantId: null });

      const organization = await findOrganizationByTenantId(found.tenantId);
      const sso = found.allowedMethods === null || found.allowedMethods.includes('federated');
      if (sso && organization?.loginUrl) return Response.json({ redirect: organization.loginUrl });

      // No SSO for this organization: an emailed code or magic link in its tenant.
      return Response.json({ method: 'passwordless', tenantId: found.tenantId });
    }
    ```

    | WorkOS call                                      | Better IAM                                                                                                                                            |
    | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
    | Get an SSO authorization URL for an organization | The SAML connection's `loginUrl`, or `/oauth/login/{connectionId}` for OIDC                                                                           |
    | Get the SSO profile after the callback           | Nothing to do for SAML: the ACS sets the Better IAM session cookie and redirects to `afterSignIn`. The OIDC callback sets the cookie and answers JSON |
    | List directory users and groups                  | `iam.api.identities.list` and `iam.api.groups.list` in the tenant                                                                                     |
    | Directory Sync webhooks                          | [Webhooks](/docs/concepts/events/webhooks) on the audit events SCIM writes (`iam:scim:*`)                                                             |
    | Generate an Admin Portal link                    | Your settings page calling `saml.createConnection`, `scim.createConnection`, and `domains.add`                                                        |
    | AuthKit session helpers                          | `iam.api.auth.getSession({ headers })` or your framework integration                                                                                  |
  
      ### Cut over [#cut-over]

    Switch one organization at a time:

    1. Its domains are verified, its connection works for a test user, and (with SCIM) the directory has created its
       people and groups.
    2. Route its sign-in to Better IAM: domain discovery now returns its tenant, and your record holds the new
       `loginUrl`.
    3. Require SSO with `tenants.setAuthPolicy` and `allowedMethods: ['federated']`, after linking the owner's SSO
       account. Imported people are linked already, and people the directory created link at their first SSO sign-in.
    4. The customer turns off provisioning in the WorkOS application, then removes it after the rollback window.

    To roll an organization back, route it to WorkOS again: its WorkOS application in the customer's identity provider
    still works, as long as they have not removed it. Changes made only in Better IAM while it was live (role mappings,
    people who joined, passwords set) do not flow back to WorkOS. See [Roll back](/docs/guides/migration#roll-back).
  
## Verify it works [#verify-it-works]

```ts title="scripts/workos-verify.ts"
import { readFile } from 'node:fs/promises';
import { iam } from '../iam';
import { saml, scim } from '../protocols';

const credential = { token: process.env.BETTER_IAM_TOKEN! };
const tenantMap = JSON.parse(await readFile('workos/tenant-map.json', 'utf8')) as Record<string, string>;

for (const [workosOrgId, tenantId] of Object.entries(tenantMap)) {
  const usage = await iam.api.tenants.usage(credential, { tenantId });
  const domains = await iam.api.domains.list(credential, { tenantId });
  const connections = await saml.listConnections(credential, { tenantId });
  const directories = await scim.listConnections(credential, { tenantId });
  console.log(workosOrgId, {
    identities: usage.identities,
    groups: usage.groups,
    domains: domains.map((domain) => `${domain.domain}:${domain.status}`),
    saml: connections.length,
    scim: directories.length,
  });
}
```

* Every domain shows `verified` before the organization switches.
* A test user from the customer signs in at the connection's `loginUrl` and lands in the right tenant.
* With SCIM, the identity and group counts match the people and groups the customer assigned, and
  `scim.listConnections` shows a recent `lastUsedAt`.

## Troubleshooting [#troubleshooting]

  #### tenants.create fails with ACCESS_DENIED

    Only a root principal may pass `activate: true`. Sign in to the CLI as the root administrator, in the root tenant,
    and export that token. See [ACCESS\_DENIED](/docs/reference/errors#access_denied).
  
  #### The first SSO sign-in fails with VERIFIED_EMAIL_REQUIRED

    The person's email domain is not in the connection's `trustedEmailDomains`, or the IdP sends no `email` (or
    `mail`) attribute. Update the connection with `saml.updateConnection`, or ask the customer to add the attribute.
    See [VERIFIED\_EMAIL\_REQUIRED](/docs/reference/errors#verified_email_required).
  
  #### The first SSO sign-in fails with ACCOUNT_LINK_REQUIRED

    An account with that email exists in the tenant but has no link for this connection. If the directory created it,
    set `trustScimProvisioned` on the connection (the owner is never linked that way). If it was imported, the user
    had no `SSO` identity in the export or its `idp_id` differs from what the IdP sends: add the link with
    `identities.linkProvider`. See [ACCOUNT\_LINK\_REQUIRED](/docs/reference/errors#account_link_required).
  
  #### The directory reports 409 uniqueness when it creates a user

    Someone with that email already exists in the tenant, usually because they were imported. Leave members of SCIM
    organizations out of the import; to fix one person, delete the imported identity and let the directory create it.
  
  #### SAML responses are refused with SAML_INVALID

    The customer's new application does not sign both the response and the assertion, or its audience or ACS URL does
    not match `entityId` and `acsUrl` exactly. See [SAML\_INVALID](/docs/reference/errors#saml_invalid).
  
  #### domains.verify keeps returning verified: false

    The TXT record is not visible yet, or was published under a different name. Compare it with `dnsRecord` from
    `domains.list`, wait for DNS to propagate, and verify again.
  
## Next steps [#next-steps]

  - [Migration guides](/docs/guides/migration): The general plan: what carries over, dual run, and rollback.

  - [Enterprise onboarding](/docs/federation/enterprise-onboarding): Domain, SSO, SCIM, and offboarding for one customer organization.

  - [SAML single sign-on with Okta](/docs/guides/enterprise-sso-okta): A full SAML setup, including account linking.

  - [SCIM provisioning](/docs/guides/scim-provisioning): Directory provisioning with Entra ID and Okta.
