Migrate from Auth0
Move users, Organizations, roles and permissions, and social and enterprise connections from Auth0 to Better IAM with identities.createMany, configuration documents, and recreated SAML, OIDC, and SCIM connections, then cut over one organization at a time.
This guide moves an application from Auth0 to Better IAM. Auth0 keeps signing people in while you import, and you switch one organization at a time, so you can point any organization back to Auth0 until you retire it. You will:
- export users, Organizations, roles, and permissions from Auth0;
- declare Auth0 API permissions as actions and recreate roles in every tenant;
- create one Better IAM tenant per Auth0 Organization and import its members with their roles, verified email status, and links to their social and enterprise identities;
- recreate social, enterprise (SAML and OIDC), and SCIM connections, and have each customer point its identity provider at them;
- optionally keep Auth0 as an upstream OpenID Connect provider during the dual run, so people keep their passwords until they switch;
- cut over, verify, and know how to roll back.
The general plan behind these steps, and the reasons for each rule, are in Migration guides.
How Auth0 concepts map
| Auth0 | Better IAM | Notes |
|---|---|---|
| Tenant (your Auth0 account) | Your deployment and its root tenant | Not a Better IAM : in Better IAM a tenant is one customer organization. |
| Application | Your application, serving iam.handler | Register OAuth clients only if Better IAM issues tokens to other applications (OAuth provider). |
| API and its permissions (scopes) | Resource types and actions in permissions.resourceTypes | Services that accept Better IAM access tokens: OAuth resource servers. |
| User | Keep the Auth0 user_id as an attribute (auth0Id below). | |
user_metadata, app_metadata | Identity attributes | Flat string, number, or boolean values declared in permissions.identityAttributes. Nested data stays in your database. |
| Database connection | Email and password | Sign-in is by email; usernames are not a sign-in identifier. |
| Passwordless connection | Magic links and codes | passwordlessEmail and passwordlessSms. |
| Social connection | createOAuthLogin connection (google, github, microsoft, oidc, oauth2) | Fixed in the deployment configuration; each connection serves one tenant. |
| Enterprise connection (SAML, Okta, Entra ID, ADFS, Google Workspace) | SAML connection (saml.createConnection) or OIDC connection | SAML connections are created at runtime per tenant from the IdP's metadata. |
| Organization | Tenant (type organization) | Each tenant has its own people, roles, sign-in policy, and connections. |
| Organization member | An identity in that tenant | A person in two Organizations gets two identities with the same email. |
| Organization invitation | identities.invite | Roles and groups apply when the invitation is accepted. |
| Role | , per tenant | Apply the same role document to every tenant. |
| Permission | Must be declared before a role can name it (INVALID_ACTION). | |
| Actions, Rules, Hooks | Webhooks, plugins, or code in your sign-in route | Webhooks deliver audit events after the fact; nothing rewrites tokens in flight. |
| Multi-factor authentication (Guardian) | MFA: authenticator apps, emailed codes, passkeys | Factors do not move; people enroll again. |
| Machine-to-machine application | with an API key | serviceAccounts.create, then credentials.create. |
| Inbound SCIM for an enterprise connection | SCIM connection | The customer replaces the SCIM URL and token in their IdP. |
| Logs | Audit log | Auth0 logs are not imported; archive them separately. |
| Universal Login | Your own sign-in pages | Better IAM has no hosted login page. |
What does not move
Password hashes (no Better IAM API accepts a hash, and Auth0's bcrypt hashes could not be loaded anyway), Guardian
and other MFA enrollments, WebAuthn passkeys, and sessions and refresh tokens. People who signed in with a password
sign in once with a magic link or a reset link. Links to social and enterprise identities are recreated at import
from each user's identities, so those people keep signing in through their provider.
Prerequisites
- A Better IAM deployment with a migrated database and a bootstrapped root administrator
(Quickstart), configured with
authentication.sendEmail. - Access to the Auth0 Management API with permission to read users, roles, permissions, Organizations, and connections.
- For each customer that uses enterprise SSO or SCIM, a contact on their IT team who can change their identity provider.
- A root administrator session for the scripts. Sign in, then export the token:
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 Auth0
Use the Auth0 Management API (or the Dashboard's export tools) to produce these files. Auth0's user export is a background job that writes newline-delimited JSON; choose the fields below.
| File | Contents | Source in Auth0 |
|---|---|---|
auth0/organizations.json | [{ id, name, display_name, ownerEmail }] | List Organizations. Pick one owner per Organization: a member who should control it in Better IAM. |
auth0/members-<org_id>.ndjson | One line per member: { user_id, email, email_verified, name, connection, identities, roles } | List the Organization's members and each member's roles, joined with the user export for email, email_verified, name, the connection the user signs in with, and identities (each with connection and user_id). |
auth0/members-default.ndjson | The same shape, for users outside any Organization | The user export, joined with each user's roles. |
auth0/roles.json | [{ name, permissions: [permission_name] }] | List roles and each role's permissions. |
auth0/connections.json | Names and types of social and enterprise connections, and which Organizations use them | List connections and each Organization's enabled connections. |
Every line in a members file must look like this; the import script reads exactly these fields:
{"user_id":"auth0|65f1c2","email":"ada@acme.com","email_verified":true,"name":"Ada Lovelace","connection":"Username-Password-Authentication","identities":[{"connection":"Username-Password-Authentication","user_id":"65f1c2"}],"roles":["Admin"]}
{"user_id":"google-oauth2|1098765","email":"grace@acme.com","email_verified":true,"name":"Grace Hopper","connection":"google-oauth2","identities":[{"connection":"google-oauth2","user_id":"1098765"}],"roles":["Member"]}In identities, user_id is the person's ID at that connection without Auth0's provider| prefix: Google's sub
for google-oauth2, the numeric user ID for github, and for enterprise connections the IdP's sub (OIDC) or, by
default, the NameID (SAML). Check one test user of each enterprise connection against what their IdP sends before
you import a whole customer.
Auth0 does not include password hashes in user exports. Do not request them for this migration: Better IAM has no way to load them.
Configure Better IAM
Declare an attribute for the Auth0 user ID and turn each Auth0 API permission into an action of a resource type.
Auth0 permission names such as read:invoices are valid action names as they are, but renaming them to your
resource types' style (invoices:read) now saves a second migration later. Turn on magic links so imported people
can sign in without a password.
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!,
authentication: {
sendEmail,
passwordlessEmail: true,
},
permissions: {
identityAttributes: { auth0Id: 'string' },
resourceTypes: {
// One entry per Auth0 API, with its permissions as actions.
invoice: { actions: ['invoices:read', 'invoices:create', 'invoices:approve'] },
},
},
});Then migrate the schema and take a snapshot you can return to:
npx better-iam migrate --config better-iam.config.mjs
npx better-iam store-export --config better-iam.config.mjs --output before-auth0-import.jsonlCreate tenants for Organizations
Each Auth0 Organization becomes a tenant. With activate: true, which your root administrator session may pass,
tenants.create makes the tenant active at once and creates the chosen owner's account, so its members can be
imported right away. No invitation is sent: the owner signs in with a magic link, a reset link, or a provider you
link to them (see the import step). Users outside any Organization go into one tenant you create for them (with your
own address as its owner).
import { readFile, writeFile } from 'node:fs/promises';
import { iam } from '../iam';
type Auth0Organization = { id: string; name: string; display_name?: string; ownerEmail: string };
const credential = { token: process.env.BETTER_IAM_TOKEN! };
const rootTenantId = process.env.ROOT_TENANT_ID!;
const organizations = JSON.parse(await readFile('auth0/organizations.json', 'utf8')) as Auth0Organization[];
const tenantMap: Record<string, string> = {};
for (const organization of organizations) {
const { tenant } = await iam.api.tenants.create(credential, {
parentId: rootTenantId,
type: 'organization',
name: organization.display_name ?? organization.name,
ownerEmail: organization.ownerEmail,
activate: true, // active now; the owner's account is created without an invitation
});
tenantMap[organization.id] = tenant.id;
console.log(`${organization.name}: tenant ${tenant.id}, owner ${organization.ownerEmail}`);
}
await writeFile('auth0/tenant-map.json', JSON.stringify(tenantMap, null, 2));npx tsx scripts/auth0-create-tenants.tstenants.create needs recent authentication: run it within five minutes of better-iam login, or sign in again.
Add ownerName to set the owner's display name, and ownerEmailVerified: true when Auth0 reports their address
verified. Only a root principal may pass activate; without it, tenants.create emails the owner an invitation
instead, and the tenant stays pending until they accept it.
Recreate roles in each tenant
Auth0 roles are global to your Auth0 tenant; Better IAM roles belong to one tenant. Build one configuration document
from auth0/roles.json and apply it to each tenant. config.apply matches roles by name, so running it again
changes nothing.
import { readFile } from 'node:fs/promises';
import { iam } from '../iam';
type Auth0Role = { name: string; permissions: string[] };
// Auth0 permission name -> Better IAM action (your renames from the configure step).
const actionFor: Record<string, string> = {
'read:invoices': 'invoices:read',
'create:invoices': 'invoices:create',
'approve:invoices': 'invoices:approve',
};
const credential = { token: process.env.BETTER_IAM_TOKEN! };
export async function applyAuth0Roles(tenantId: string): Promise<void> {
const roles = JSON.parse(await readFile('auth0/roles.json', 'utf8')) as Auth0Role[];
const document = {
version: 1,
roles: roles.map((role) => {
const permissions = role.permissions.map((permission) => {
const action = actionFor[permission];
if (!action) throw new Error(`No action mapped for Auth0 permission ${permission}`);
return action;
});
// A permissions list must name at least one action; a role without any grants nothing yet.
return permissions.length ? { name: role.name, permissions } : { name: role.name };
}),
};
const plan = await iam.api.config.plan(credential, { tenantId, config: document });
console.log(tenantId, plan.summary);
await iam.api.config.apply(credential, { tenantId, config: document });
}Import members
The import script sorts each member by the Auth0 connection they sign in with, and links every Auth0 identity it can map to a Better IAM connection, so the person's first sign-in through that connection finds their imported account:
| Connection | What the script does | How they sign in afterwards |
|---|---|---|
| Database or passwordless connection | Imports them with their roles. | Magic link, or a reset link. |
| Social connection | Imports them with their roles and a link to the tenant's connection for that provider. | Through the provider, as before. |
| Enterprise connection | Imports them with their roles and a link to the customer's connection, once you have created it (next steps); until then, leaves them for a later run. | Their company's SSO. |
| Enterprise connection whose directory provisions people through SCIM | Never imports them. | Their company's SSO; the directory creates the account and the connection's trustScimProvisioned links it. |
import { createReadStream } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { createInterface } from 'node:readline';
import { iam } from '../iam';
import { saml } from '../saml';
import { applyAuth0Roles } from './auth0-roles';
type Auth0Member = {
user_id: string;
email?: string;
email_verified?: boolean;
name?: string;
connection: string;
identities?: Array<{ connection: string; user_id: string | number }>;
roles: string[];
};
type Link = { providerId: string; issuer: string };
// Your Auth0 connection names.
const DATABASE_CONNECTIONS = new Set(['Username-Password-Authentication', 'email']);
const DIRECTORY_CONNECTIONS = new Set(['acme-okta']); // the customer's SCIM directory creates these people
// Auth0 connection name -> the OAuth connection that replaces it, per Auth0 Organization (see the next steps).
// providerId is the connection's id; issuer is the provider's `iss`, exactly.
const OAUTH_CONNECTIONS: Record<string, Record<string, Link>> = {
org_acme: {
'google-oauth2': { providerId: 'acme-google', issuer: 'https://accounts.google.com' },
},
org_globex: {
'globex-okta': { providerId: 'globex-okta', issuer: 'https://globex.okta.com' },
},
};
const credential = { token: process.env.BETTER_IAM_TOKEN! };
/** The tenant's link targets: its OAuth connections, plus each SAML connection under the Auth0 connection's name. */
async function linkTargets(auth0OrgId: string, tenantId: string): Promise<Map<string, Link>> {
const targets = new Map(Object.entries(OAUTH_CONNECTIONS[auth0OrgId] ?? {}));
for (const connection of await saml.listConnections(credential, { tenantId }))
targets.set(connection.id, { providerId: connection.providerId, issuer: connection.idpIssuer });
return targets;
}
async function readMembers(path: string): Promise<Auth0Member[]> {
const members: Auth0Member[] = [];
const lines = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
for await (const line of lines) if (line.trim()) members.push(JSON.parse(line) as Auth0Member);
return members;
}
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;
}
}
async function importOrganization(auth0OrgId: string, tenantId: string): Promise<void> {
await applyAuth0Roles(tenantId);
const roleIds = new Map((await iam.api.roles.list(credential, { tenantId })).map((role) => [role.name, role.id]));
const targets = await linkTargets(auth0OrgId, tenantId);
const seen = await existingEmails(tenantId);
const members = await readMembers(`auth0/members-${auth0OrgId}.ndjson`);
const toImport: Array<{ member: Auth0Member; providers: Array<Link & { subject: string }> }> = [];
let waiting = 0;
for (const member of members) {
if (!member.email) continue; // Better IAM needs an email address
const email = member.email.trim().toLowerCase();
if (seen.has(email)) continue; // already imported, or the tenant's owner
if (DIRECTORY_CONNECTIONS.has(member.connection)) continue; // the directory creates them
if (!DATABASE_CONNECTIONS.has(member.connection) && !targets.has(member.connection)) {
waiting++; // their connection is not recreated in this tenant yet: a later run imports them
continue;
}
// Every Auth0 identity with a Better IAM connection becomes a link (one per connection).
const providers = new Map<string, Link & { subject: string }>();
for (const identity of member.identities ?? []) {
const target = targets.get(identity.connection);
if (target) providers.set(target.providerId, { ...target, subject: String(identity.user_id) });
}
seen.add(email);
toImport.push({ member, providers: [...providers.values()] });
}
const idMapPath = `auth0/id-map-${auth0OrgId}.json`;
const idMap: Record<string, string> = JSON.parse(await readFile(idMapPath, 'utf8').catch(() => '{}'));
for (let start = 0; start < toImport.length; start += 100) {
const batch = toImport.slice(start, start + 100);
const { identities } = await iam.api.identities.createMany(credential, {
tenantId,
identities: batch.map(({ member, providers }) => ({
email: member.email!,
name: member.name || member.email!,
emailVerified: member.email_verified === true,
providers,
attributes: { auth0Id: member.user_id },
roleIds: member.roles.flatMap((name) => roleIds.get(name) ?? []),
})),
});
batch.forEach(({ member }, i) => (idMap[member.user_id] = identities[i]!.id));
}
await writeFile(idMapPath, JSON.stringify(idMap, null, 2));
console.log(`${auth0OrgId}: imported ${toImport.length}, ${waiting} wait for their connection`);
}
const tenantMap = JSON.parse(await readFile('auth0/tenant-map.json', 'utf8')) as Record<string, string>;
for (const [auth0OrgId, tenantId] of Object.entries(tenantMap)) await importOrganization(auth0OrgId, tenantId);npx tsx scripts/auth0-import-members.tsRun it again after you recreate each customer's enterprise connection, and during the dual run to pick up new Auth0
sign-ups: people already imported are skipped. Each batch of 100 is one transaction, so if a batch fails (for example
LIMIT_EXCEEDED on a tenant's plan limit, or ACCOUNT_LINK_CONFLICT for an Auth0 identity listed under two users),
fix the cause and run the script again. The id-map-*.json files map each Auth0 user_id to the new identity ID
for your own tables.
emailVerifiedis set only where Auth0 saysemail_verified: true; the others verify their address with their first magic link.- The SAML link targets come from the
samlservice you mount in Recreate enterprise connections, under the connectionidyou give each customer's connection (the Auth0 connection's name here). Drop that loop if you have no SAML customers. - An Auth0 connection with no entry, for example a social provider you do not recreate, gets no link; those people sign in with a magic link.
- The owner already exists, so the script skips them. If they sign in through a provider, link it with
iam.api.identities.linkProvider(credential, { tenantId, identityId, providerId, issuer, subject }), which takes the same values as aprovidersentry and needs a sign-in from the last five minutes.
Recreate social connections
Social sign-in becomes a createOAuthLogin connection per provider and tenant. If Auth0 used your own client ID
and secret at Google, GitHub, or Microsoft, reuse them and add the new redirectUri to that client's allowed
callback URLs at the provider; Auth0's developer keys cannot be reused.
import { createOAuthLogin } from 'better-iam/oauth';
import { iam } from './iam';
export const login = createOAuthLogin({
...iam.protocolHost,
trustedOrigins: ['https://app.example.com'],
connections: [
{
id: 'acme-google',
tenantId: process.env.ACME_TENANT_ID!,
kind: 'google',
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: 'https://app.example.com/oauth/acme-google/callback',
},
],
});
iam.useProtocol(login);Sign-in starts at /oauth/login/acme-google. The connection id is the providerId in the import script's
OAUTH_CONNECTIONS, so a person the import linked to acme-google signs in to their imported account, with its
roles, on their first Google sign-in. Someone who was not in the export is created on their first sign-in with a
verified email.
Recreate enterprise connections and SCIM
For each customer with an Auth0 enterprise connection, recreate it in that customer's tenant. Their IT team adds a second application in their identity provider for Better IAM, so the Auth0 one keeps working until you switch them.
Mount the SAML service once, with your service-provider key pair:
import { createSamlService } from 'better-iam/saml';
import { iam } from './iam';
export const saml = createSamlService({
...iam.protocolHost,
serviceProvider: {
baseUrl: 'https://app.example.com',
privateKey: process.env.SAML_SP_KEY!,
publicCertificate: process.env.SAML_SP_CERTIFICATE!,
},
afterSignIn: '/dashboard', // the ACS redirects here with the session cookie set
errorUrl: '/login', // failed sign-ins land here with ?error=CODE
});
iam.useProtocol(saml);With afterSignIn, the ACS answers a completed sign-in with a redirect instead of JSON, so no route of your own has
to turn the answer into a page. ?returnTo=/path on a connection's login URL picks the page for one sign-in, and an
optional mfaUrl receives people who still have to pass MFA.
Then create the customer's connection from their IdP's metadata file:
import { readFile } from 'node:fs/promises';
import { saml } from '../saml';
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('acme-idp-metadata.xml', 'utf8'),
trustedEmailDomains: ['acme.com'],
});
console.log('Entity ID:', connection.entityId, 'ACS URL:', connection.acsUrl, 'Login URL:', connection.loginUrl);The customer enters the entity ID and ACS URL in the new application, with both the response and the assertion
signed and an email attribute. For a customer without a SCIM directory, run the import again afterwards: it links
each of the customer's Auth0 users to this connection (connection.providerId, connection.idpIssuer, and their
NameID), so their first SSO sign-in finds their imported account. trustedEmailDomains lets people who were not
imported create their account at their first sign-in, and marks their address verified. See
SAML.
People a SCIM directory manages are not imported (the directory's create would fail with a 409 uniqueness error).
With trustScimProvisioned, each one's first SSO sign-in, with an address in the connection's trustedEmailDomains,
links to the account the directory created, with no step in between. For an OIDC connection, set
trustScimProvisioned (true, or a list of SCIM connection IDs) on its entry in createOAuthLogin. You can
restrict the tenant to federated sign-in with tenants.setAuthPolicy as soon as the connection works; link the
owner's SSO account with identities.linkProvider first, because trustScimProvisioned never links an owner.
Optionally bridge passwords through Auth0
Auth0 is an OpenID Connect provider, so during the dual run you can add it as an upstream oidc connection for
people who sign in with a password. They sign in at Auth0 with the password and factors they already have, and
their first sign-in creates their Better IAM account with a verified email. Auth0's ID token must say
email_verified: true; otherwise that first sign-in fails with VERIFIED_EMAIL_REQUIRED.
Create a Regular Web Application in Auth0 for this, with the redirectUri below as its callback URL.
import { createOAuthLogin } from 'better-iam/oauth';
import { iam } from './iam';
export const login = createOAuthLogin({
...iam.protocolHost,
connections: [
{
id: 'auth0-bridge',
tenantId: process.env.APP_TENANT_ID!,
kind: 'oidc',
issuer: 'https://your-tenant.us.auth0.com/', // exactly as in Auth0's discovery document
clientId: process.env.AUTH0_BRIDGE_CLIENT_ID!,
clientSecret: process.env.AUTH0_BRIDGE_CLIENT_SECRET!,
redirectUri: 'https://app.example.com/oauth/auth0-bridge/callback',
},
],
});
iam.useProtocol(login);Imported people can use the bridge too, keeping the roles the import gave them: link each database-connection
member of that tenant to it. Auth0's ID tokens carry the full user_id (such as auth0|65f1c2) as sub, so in the
import script add this to the member's providers when the member signs in with a database connection:
// Inside the loop, for the tenant the bridge serves (APP_TENANT_ID):
if (tenantId === process.env.APP_TENANT_ID && DATABASE_CONNECTIONS.has(member.connection))
providers.set('auth0-bridge', {
providerId: 'auth0-bridge',
issuer: 'https://your-tenant.us.auth0.com/', // the connection's issuer, trailing slash included
subject: member.user_id,
});Without that link, an imported person's first sign-in through the bridge fails with ACCOUNT_LINK_REQUIRED. When
you retire Auth0, remove the connection; those people sign in with a magic link or a reset link afterwards.
Replace Auth0 in your application
| In your code today | With Better IAM |
|---|---|
| Redirect to Universal Login | Your sign-in page calling auth.signIn, auth.startPasswordless, or /oauth/login/{connectionId} and the SAML loginUrl |
| Read the session (Auth0 SDK) | iam.api.auth.getSession({ headers }), or iamNext.getSession() in Next.js |
| Check permissions in the access token | iam.require(...) or iam.authorize(...) on the server (Protect an API) |
| Management API calls | iam.api.* with an administrator credential |
| Actions on post-login | Code in your own sign-in route, or a webhook for after-the-fact work |
| M2M client credentials | An API key of a service account, sent as Authorization: Bearer |
Cut over
Switch one organization at a time:
- Confirm its people, roles, and connections (Verify it works).
- Route its sign-in to Better IAM (a flag on your own organization record).
- Tell its people how to sign in the first time: a magic link, their company's SSO, or the social provider.
- For SSO customers, restrict the tenant with
tenants.setAuthPolicy({ tenantId, authPolicy: { allowedMethods: ['federated'] } }). Imported people are linked and SCIM-created people link at their first SSO sign-in, so this can happen at the switch, once the owner's SSO account is linked.
Keep Auth0 running, unchanged, until the rollback window ends. To roll an organization back, flip its flag: its people sign in to Auth0 again with everything they had there. Accounts created and passwords set in Better IAM after the switch do not exist in Auth0, and no API exports a password hash, so those people reset their password in Auth0. See Roll back.
Verify it works
For each tenant:
import { iam } from '../iam';
const credential = { token: process.env.BETTER_IAM_TOKEN! };
const tenantId = process.env.TENANT_ID!;
const usage = await iam.api.tenants.usage(credential, { tenantId });
console.log('identities', usage.identities, 'roles', usage.roles, 'groups', usage.groups);
// Spot-check one imported person's access against what they could do in Auth0.
const { allowed } = await iam.api.policies.effectiveActions(credential, {
tenantId,
identityId: process.env.SAMPLE_IDENTITY_ID!,
resource: { type: 'invoice', id: 'sample' },
});
console.log('allowed actions', allowed);usage.identitiesequals the imported members plus the owner and anyone a SCIM directory or a first sign-in created.- A person imported from the database connection receives a magic link (
auth.startPasswordlesswithchannel: 'email') and signs in;auth.getSessionthen showsemailVerified: trueon their identity. - A person imported from a social connection signs in through the provider and lands in their imported account with
its roles;
identities.listProviderslists the link the import created. - A customer's SSO test user signs in at the connection's
loginUrland lands in the right tenant.
Troubleshooting
Next steps
The general plan: what carries over, dual run, and rollback.
SAML single sign-on with OktaA full SSO setup for one customer, including account linking.
SCIM provisioningLet a customer's directory create and remove its people.
Magic links and codesThe first sign-in for every imported person.
Better IAM is created by Sean Filimon
Last updated
Migration guides
The general plan for moving people, organizations, roles, SSO connections, and directories to Better IAM: what carries over, what cannot, the bulk import APIs, password handling, dual-run cut-over, and rollback.
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.