Migrate 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.
How WorkOS concepts map
| WorkOS | Better IAM | Notes |
|---|---|---|
| Environment (staging, production) | One deployment per environment | |
| Organization | (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 | created by SCIM | |
| Directory 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 connects them). |
| Role and permissions | per tenant, with | Declare actions in permissions.resourceTypes. |
| Magic Auth | Emailed codes (auth.startPasswordless with kind: 'code') | |
| Sessions and access tokens | Sessions and session tokens | |
| Impersonation | Impersonation | |
| Events and webhooks | 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 | 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
- A Better IAM deployment with a migrated database and a bootstrapped root administrator
(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:
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
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:
[
{
"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
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'] },
},
},
});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). 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:
npx better-iam migrate --config better-iam.config.mjs
npx better-iam store-export --config better-iam.config.mjs --output before-workos-import.jsonlCreate tenants and claim domains
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:
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
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.
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.
Re-point SCIM directories
For each organization whose directory pushes through SCIM, create a connection and send the customer its URL and token:
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:
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 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.
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}`);
}npx tsx scripts/workos-import-users.tsImported 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
CONNECTIONSfor each social connection you recreate in its tenant, for exampleGoogleOAuth: { 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.
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.
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 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
Switch one organization at a time:
- Its domains are verified, its connection works for a test user, and (with SCIM) the directory has created its people and groups.
- Route its sign-in to Better IAM: domain discovery now returns its tenant, and your record holds the new
loginUrl. - Require SSO with
tenants.setAuthPolicyandallowedMethods: ['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. - 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.
Verify it works
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
verifiedbefore the organization switches. - A test user from the customer signs in at the connection's
loginUrland lands in the right tenant. - With SCIM, the identity and group counts match the people and groups the customer assigned, and
scim.listConnectionsshows a recentlastUsedAt.
Troubleshooting
Next steps
Better IAM is created by Sean Filimon
Last updated
Migrate from Clerk
Move users, organizations, memberships, organization roles and permissions, and metadata from Clerk to Better IAM with identities.createMany and configuration documents, recreate social and enterprise SSO, replace Clerk in a Next.js app, and cut over.
Migrate from Better Auth
Move users, organizations, members, and roles from a Better Auth database to Better IAM with identities.createMany, re-establish passwords and social sign-in, and cut over a Next.js app.