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.
Moving to Better IAM from a hosted identity provider or an authentication library is mostly a data move: the people, the organizations they belong to, the roles they hold, and the SSO and directory connections their companies use. This page is the general plan that every provider-specific guide follows. It covers what carries over and what cannot, the bulk import APIs, how imported people sign in without their old password, how to run both systems side by side while you switch, and how to roll back.
The short version:
- People, organizations, roles, groups, and role assignments move through the API, 100 people per transaction.
- Verified email status and links to social or enterprise accounts move with each person, so people who signed in through a provider keep doing so. Password hashes, authenticator secrets, passkeys, and sessions do not move: everyone else signs in once with a magic link or a reset link.
- SSO and SCIM connections are recreated, and each customer's IT team points its identity provider at the new connection.
- You switch over one organization at a time while the old system keeps running, so rolling back means pointing that organization back.
Pick a guide
Database, social, and enterprise connections, Organizations, roles and permissions, and metadata.
From ClerkUsers, organizations and memberships, organization roles and permissions, metadata, and SSO.
From WorkOSOrganizations, SSO connections, Directory Sync, domains, and AuthKit users.
From Better AuthUsers, sessions, and organizations from Better Auth.
From Auth.jsUsers and accounts from Auth.js (NextAuth).
From KeycloakRealms, users, groups, and roles from Keycloak.
What carries over
| Data | Carries over | How |
|---|---|---|
| People (email, display name) | Yes | identities.createMany, up to 100 per transaction. |
| Email verification | Yes | emailVerified: true on each createMany entry whose address the old system verified. |
| Old user IDs | Yes | As an identity attribute such as legacyId, declared in permissions.identityAttributes. |
| Custom profile data (metadata) | Partly | Flat string, number, and boolean attributes declared in permissions.identityAttributes (strings up to 2,048 characters). Nested objects and arrays stay in your own database. |
| Usernames | As data only | Sign-in is by email address. Keep a username as an attribute if you display it. |
| Phone numbers | No | A phone number is set only when the person verifies it (auth.startPhoneVerification). Every identity needs an email address, so phone-only accounts cannot be imported. |
| Organizations | Yes | tenants.create, one per organization. With activate: true (root principals only) the tenant and its owner's account are created active, with no invitation; otherwise the tenant waits for its owner to accept an emailed invitation. |
| Roles and permissions | Yes | roles.create, or a configuration document applied with config.apply. Permission names become actions in your catalog. |
| Role assignments | Yes | roleIds on each createMany entry, or bindings.create. |
| Groups and memberships | Yes | groups.create and groups.addMembers, or groups in a configuration document. |
| Managers | Yes | identities.update with managerId, after both people exist. |
| Password hashes | No | No API accepts a hash. See Passwords. |
| Authenticator apps, SMS factors, recovery codes | No | People enroll again. A tenant that requires MFA asks them on their next sign-in. |
| Passkeys | No | People register a new passkey after they sign in. |
| Sessions and refresh tokens | No | Everyone signs in once after the switch. |
| Links to social and enterprise accounts | Yes | providers on each createMany entry, or identities.linkProvider for an existing account: the connection, the provider's issuer, and the person's subject there. See Social and SSO accounts. |
| SAML and OIDC connections | Recreated | A new connection per customer; their IT team registers the new service-provider values. |
| SCIM directories | Recreated | A new SCIM connection per customer; their IT team replaces the URL and token in their identity provider. |
| Verified domains | Recreated | domains.add, then a new DNS TXT record and domains.verify. |
| Machine-to-machine clients | Recreated | A with an API key (serviceAccounts.create, credentials.create). |
| Audit logs | No | Better IAM starts its own tamper-evident chain. Export and archive the old provider's logs separately. |
Map the concepts
Hosted providers use similar words for different things. The provider guides have a full table for each provider; these are the mappings that change how you plan.
| Hosted-provider concept | Better IAM | What changes |
|---|---|---|
| User | An identity belongs to exactly one tenant. | |
| Organization | Tenant (type organization in the default hierarchy) | Each tenant has its own directory, roles, sign-in policy, and SSO connections. |
| Organization membership | An identity in that tenant | A person in three organizations has three identities with the same email address. Opt-in account linking lets them switch between those accounts without merging them. |
| Role | , defined per tenant | Create the same roles in every tenant, for example by applying one configuration document to each. |
| Permission | declared in permissions.resourceTypes | Roles that name an undeclared action are refused with INVALID_ACTION. |
| Role assignment | Issued under a ; a root administrator receives one automatically. | |
| SSO connection | SAML connection (better-iam/saml) or OAuth/OIDC connection (better-iam/oauth) | SAML connections can be created at runtime per tenant; OAuth connections are fixed in the deployment configuration, and each one serves a single tenant. |
| Directory sync | SCIM connection (better-iam/scim) | SCIM 2.0 push only. Directories the old provider read by polling an API have no equivalent. |
| User metadata | Identity attributes | Typed, flat, and declared up front; policies read them as principal.<name>. |
| Hosted login page | Your own sign-in screens | Better IAM has no hosted pages. Build them with the typed client or a framework integration. |
| Post-login hooks and event webhooks | Webhooks and plugins | Webhooks carry audit events after the fact; they cannot change a sign-in in flight. |
Passwords, factors, and sessions
Password hashes
Better IAM hashes passwords with Argon2id and sets them only from plaintext. No API accepts a precomputed hash, and there is no hook that checks a legacy hash at first sign-in. Exporting bcrypt, scrypt, PBKDF2, or any other hashes from the old provider therefore gains nothing: they cannot be loaded. See Hashing.
People you import without a password are active and have no password. Their email address is unverified unless you
pass emailVerified: true, which you should do only when the old system says the address is verified. Choose how
each group of people signs in:
| People who today | Do this |
|---|---|
| Sign in with a password or a magic link | Turn on magic links (authentication.passwordlessEmail: true); a magic-link sign-in also verifies an unverified address. Or send each person a reset link with identities.requestPasswordReset. |
| Sign in with Google, GitHub, Microsoft, or another social provider | Import them with a providers entry for that provider's connection (Social and SSO accounts). Their first sign-in through it finds their account, with its roles. |
| Sign in with their company's SAML or OIDC identity provider | Import them with a providers entry for the company's connection when you know their subject at its IdP. Or leave them out: their first SSO sign-in creates the account when the connection trusts their email domain, or the company's SCIM directory creates them and the connection's trustScimProvisioned links their first SSO sign-in. |
Two traps
The public "forgot password" flow (auth.requestPasswordReset) sends nothing to an unverified address, so people
imported without emailVerified: true cannot reset a password on their own until a magic link or an
auth.requestEmailVerification link has verified it (an administrator's reset link sets a password but leaves the
address unverified). And accounts are never merged by email: the first sign-in through a provider that has no link
to an imported account with the same address fails with ACCOUNT_LINK_REQUIRED. Pass providers at import, or add
the link later with identities.linkProvider.
identities.requestPasswordReset needs iam:identities:update and recent authentication: a session signed in
within recentAuthenticationMs (five minutes by default), or an API key created within that window. It queues the
password-reset email whether or not the address is verified.
Capture passwords at first sign-in
If the old provider can still verify a password from your server, you can keep people's passwords without ever seeing a hash: when someone signs in during the dual run and has no Better IAM account yet, check the password with the old provider, then create the identity with that password. This is code you write in your application, not a Better IAM feature, and it only works for people who have not been imported yet, because no API sets a password on an existing identity.
import { IamError } from 'better-iam';
import { iam } from '@/lib/iam';
import { verifyWithOldProvider } from '@/lib/old-provider'; // your code: returns { name } or null
const migration = { token: process.env.BETTER_IAM_MIGRATION_TOKEN! }; // API key with iam:identities:create and :read
const tenantId = process.env.APP_TENANT_ID!;
export async function POST(request: Request) {
const { email, password } = (await request.json()) as { email: string; password: string };
const normalized = email.trim().toLowerCase();
const matches = await iam.api.identities.list(migration, { tenantId, query: normalized, limit: 20 });
if (!matches.some((identity) => identity.email === normalized)) {
const legacy = await verifyWithOldProvider(normalized, password);
if (legacy) {
try {
await iam.api.identities.create(migration, { tenantId, email: normalized, name: legacy.name, password });
} catch (error) {
// Too short for the password policy, found in a breach list, or created concurrently:
// leave the person for the bulk import, where they sign in with a magic link instead.
if (!(error instanceof IamError)) throw error;
}
}
}
return new Response(null, { status: 204 }); // same answer either way
}Your sign-in form posts to this route first, then signs in with Better IAM as usual (auth.signIn). Keep these
points in mind:
- Passwords must satisfy the Better IAM password policy (at least 12 characters, plus any tenant rules), so shorter
legacy passwords are refused with
WEAK_PASSWORDand those people use the fallback. - The new identity's email is unverified unless you also pass
emailVerified: true(only when the old provider reports the address verified). Withauthentication.requireEmailVerificationon, an unverified person's sign-in fails withEMAIL_UNVERIFIEDuntil they follow the link fromauth.requestEmailVerification. - Rate-limit the route like any sign-in endpoint, and keep the old provider running until the route is removed.
Bridge sign-in through the old provider
If the old provider can act as an OpenID Connect provider for other applications (Auth0 can, for example), add it
as an upstream kind: 'oidc' connection during the dual run. People sign in at the old provider with the password
and factors they already have; on their first sign-in Better IAM creates their account with a verified email and links it
to the old provider's subject. The ID token must say email_verified: true; otherwise that first sign-in fails with
VERIFIED_EMAIL_REQUIRED.
import { createOAuthLogin } from 'better-iam/oauth';
import { iam } from './iam';
export const login = createOAuthLogin({
...iam.protocolHost,
connections: [
{
id: 'legacy',
tenantId: process.env.APP_TENANT_ID!,
kind: 'oidc',
issuer: process.env.LEGACY_ISSUER!, // the old provider's issuer URL, as in its discovery document
clientId: process.env.LEGACY_CLIENT_ID!,
clientSecret: process.env.LEGACY_CLIENT_SECRET!,
redirectUri: 'https://app.example.com/oauth/legacy/callback',
},
],
});
iam.useProtocol(login);People you import can use the bridge too: give each one a providers entry with providerId: 'legacy', the old
provider's issuer, and the sub its ID tokens carry for that person (usually the old user ID), as in
Social and SSO accounts. Without that entry, an imported person's first sign-in through
the bridge fails with ACCOUNT_LINK_REQUIRED. When you retire the old provider, remove the connection; its accounts
stay, have no password, and sign in with a magic link or a reset link from then on.
Factors, passkeys, and sessions
TOTP secrets, SMS factors, recovery codes, and passkeys stay with the system that issued them; passkeys are also
bound to the old provider's relying-party domain. People enroll again: if a tenant's policy sets requireMfa,
people without a factor enroll at their next sign-in (MFA). Sessions and refresh
tokens do not carry over either, so everyone signs in once after the switch.
Social and SSO accounts
Better IAM finds the account for a federated sign-in by its link: the connection, the provider's issuer, and the
person's subject at the provider. Create those links at import with providers on each createMany entry (up to 10
per person, one per connection), or later with identities.linkProvider. The person's first sign-in through a linked
connection then signs in to the imported account, with its roles, instead of failing with ACCOUNT_LINK_REQUIRED.
| Connection | providerId | issuer | subject |
|---|---|---|---|
Google (kind: 'google') | The connection's id | https://accounts.google.com | Google's sub |
GitHub (kind: 'github') | The connection's id | https://github.com | The numeric GitHub user ID, as a string |
OIDC (kind: 'oidc' or 'microsoft') | The connection's id | The provider's iss, exactly (Entra ID: https://login.microsoftonline.com/<tenant-id>/v2.0; Auth0 keeps the trailing slash) | The ID token's sub |
SAML connection from saml.createConnection | The connection's providerId (returned by createConnection and getConnection) | The connection's idpIssuer | The person's NameID |
Old systems record these under their own names, for example a provider called google-oauth2 or oauth_google.
Map each one to the connection you created for that provider in the person's tenant:
type LegacyAccount = { provider: string; subject: string }; // from the old system's export
/** The old system's provider name -> the Better IAM connection that replaces it, and that provider's issuer. */
const connections: Record<string, { providerId: string; issuer: string }> = {
google: { providerId: 'app-google', issuer: 'https://accounts.google.com' },
github: { providerId: 'app-github', issuer: 'https://github.com' },
};
export function providerLinks(accounts: LegacyAccount[]) {
return accounts.flatMap(({ provider, subject }) => {
const connection = connections[provider];
return connection ? [{ ...connection, subject }] : []; // other providers: magic link instead
});
}A subject must be the provider's stable user ID exactly as the connection reports it at sign-in; an email address
or a display name never matches. An external account links to one identity only, and each identity has at most one
link per connection: a second link fails with
ACCOUNT_LINK_CONFLICT. identities.linkProvider needs
iam:identities:update and recent authentication, and only another owner or a root principal links an owner;
identities.listProviders and identities.unlinkProvider show and remove links.
Bulk import APIs
| Method | Route | Batch | Use it for |
|---|---|---|---|
tenants.create | POST /tenants/create | 1 | One organization. With activate: true (root principals only) it is active at once with its owner's account; otherwise the owner gets an invitation, which needs sendEmail. Needs recent authentication. |
config.apply | POST /config/apply | One document | Roles, policies, groups with members (by email), and group-to-role bindings, matched by name, in one transaction. config.plan previews it. |
identities.createMany | POST /identities/createMany | 1 to 100 | People, with attributes, direct roles, groups, an optional password, an optional deactivation date, verified email status (emailVerified), and links to external sign-ins (providers). All or nothing per batch. |
identities.linkProvider | POST /identities/linkProvider | 1 | A link to an external sign-in for a person who already exists, such as a tenant's owner. Needs recent authentication. |
groups.addMembers | POST /groups/addMembers | Up to 100 | Adding existing people to one group. |
bindings.create | POST /bindings/create | 1 | A role for a person or group, for example for people who arrived through SSO. |
identities.update | POST /identities/update | 1 | Managers (managerId) and attribute corrections after the import. |
domains.add, domains.verify | POST /domains/add, POST /domains/verify | 1 | Re-verifying each organization's email domains. |
identities.list | POST /identities/list | Up to 1,000 per page | Finding people already imported, so a re-run skips them. |
Routes are relative to the handler's basePath (/api/iam by default). From a terminal,
better-iam api identities.createMany --data @batch.json --tenant TENANT_ID calls the same method with the same
permission checks; see the CLI.
Plan the migration
The provider guides fill in these steps with each provider's export format. The code here assumes an instance
module iam.ts that exports iam, and a credential for a root administrator or for the organization's owner.
Inventory and export
List what the old system holds and decide where each part goes: people by sign-in method (password, social, SSO), organizations and their owners, roles and the permissions they contain, groups, SSO connections, and directory connections. Export people as files you keep (JSON Lines works well), with whether each address is verified and each person's user ID at every social or SSO provider they use, and record counts, so you can compare them after the import.
Freeze changes you cannot replay: once the export is taken, new sign-ups, role changes, and deletions in the old system must either wait or be applied again later. The import below skips people who already exist, so re-running it picks up new sign-ups.
Configure the deployment
Declare the attribute that keeps each person's old ID, the actions the old permissions become, and the delivery callback that magic links, reset links, and invitations need:
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, // magic links, reset links, invitations
passwordlessEmail: true, // imported people sign in with a magic link the first time
},
permissions: {
identityAttributes: { legacyId: 'string' },
resourceTypes: {
invoice: { actions: ['invoices:read', 'invoices:create'] },
},
},
});Run better-iam migrate against the new database, then better-iam bootstrap once to create the root
administrator, as in the Quickstart. Before the first import, take a
snapshot you can return to with better-iam store-export --config better-iam.config.mjs --output before-import.jsonl
(Snapshots).
Recreate organizations
Create one tenant per organization. With activate: true, which only a root principal may pass, the tenant is
active at once and its owner's account exists, so you can import its people straight away. No invitation is sent:
the owner signs in with a magic link, a reset link, or an external sign-in you link to them with
identities.linkProvider. Pass ownerEmailVerified: true only when the old system verified the owner's address.
import { writeFile } from 'node:fs/promises';
import { iam } from '../iam';
const credential = { token: process.env.BETTER_IAM_TOKEN! }; // a root administrator's session
const rootTenantId = process.env.ROOT_TENANT_ID!;
type Organization = {
legacyId: string;
name: string;
ownerEmail: string;
ownerName: string;
ownerEmailVerified: boolean; // as the old system reports it
};
const organizations = JSON.parse(process.env.ORGANIZATIONS_JSON ?? '[]') as Organization[];
const tenantIds: 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, owner created without an invitation
ownerName: organization.ownerName,
ownerEmailVerified: organization.ownerEmailVerified,
});
tenantIds[organization.legacyId] = tenant.id;
}
await writeFile('tenant-map.json', JSON.stringify(tenantIds, null, 2));tenants.create needs recent authentication, so run it soon after signing in. Without activate, as when an
organization's owner signs up on their own, each tenant starts pending and becomes active only when its owner
accepts the emailed invitation with tenants.acceptInvitation; nobody can be added before that
(TENANT_INACTIVE). A product without organizations creates one tenant for all of its people.
Recreate roles and groups
Roles belong to a tenant. Write them once as a configuration document and apply it to every tenant, so each organization gets the same roles under the same names:
import { iam } from '../iam';
const credential = { token: process.env.BETTER_IAM_TOKEN! };
export const rolesDocument = {
version: 1,
roles: [
{ name: 'Admin', permissions: ['invoices:read', 'invoices:create'] },
{ name: 'Member', permissions: ['invoices:read'] },
],
};
export async function applyRoles(tenantId: string) {
const plan = await iam.api.config.plan(credential, { tenantId, config: rolesDocument });
console.log(tenantId, plan);
await iam.api.config.apply(credential, { tenantId, config: rolesDocument });
}Groups whose members are already imported can go in the same document (groups with members as emails, and
bindings from group to role).
Import people
Import 100 people per call, skip anyone who already exists, and keep the mapping from old ID to new ID for your own tables. Carry over each person's verified email status and their links to social and SSO accounts (Social and SSO accounts). Each batch is one transaction: if one entry fails, nobody in that batch is created, so a stopped script resumes at the failed batch.
import { readFile, writeFile } from 'node:fs/promises';
import { iam } from '../iam';
type Person = {
legacyId: string;
email: string;
emailVerified: boolean; // as the old system reports it
name: string;
roles: string[];
providers: Array<{ providerId: string; issuer: string; subject: string }>; // see providerLinks()
};
const credential = { token: process.env.BETTER_IAM_TOKEN! };
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;
}
}
export async function importPeople(tenantId: string, people: Person[]): Promise<Record<string, string>> {
const roleIds = new Map(
(await iam.api.roles.list(credential, { tenantId })).map((role) => [role.name, role.id]),
);
const seen = await existingEmails(tenantId);
const pending = people.filter((person) => {
const email = person.email.trim().toLowerCase();
if (seen.has(email)) return false; // already imported, or the tenant's owner
seen.add(email); // also drops duplicates inside the export
return true;
});
const idMap: Record<string, string> = {};
for (let start = 0; start < pending.length; start += 100) {
const batch = pending.slice(start, start + 100);
const { identities } = await iam.api.identities.createMany(credential, {
tenantId,
identities: batch.map((person) => ({
email: person.email,
name: person.name || person.email,
emailVerified: person.emailVerified,
providers: person.providers,
attributes: { legacyId: person.legacyId },
roleIds: person.roles.flatMap((name) => roleIds.get(name) ?? []),
})),
});
// Results come back in the order sent: identities[i] replaces batch[i].
batch.forEach((person, i) => (idMap[person.legacyId] = identities[i]!.id));
}
return idMap;
}
const tenantId = process.env.TENANT_ID!;
const people = JSON.parse(await readFile(process.argv[2] ?? 'people.json', 'utf8')) as Person[];
const idMap = await importPeople(tenantId, people);
await writeFile(`id-map-${tenantId}.json`, JSON.stringify(idMap, null, 2));Handing out roles needs iam:bindings:create on each role and an active grant authority in the tenant; a root
administrator or the tenant's owner has both. Tenant plan limits apply (LIMIT_EXCEEDED).
Recreate SSO and directory connections
For each organization that signs in through its company identity provider:
- Verify the domain.
domains.addreturns a DNS TXT record for the customer to publish, thendomains.verifychecks it. A verified domain letsdomains.discoverroute people to the right organization from their email address. - Create the connection. For SAML,
saml.createConnectionwith the IdP's metadata file andtrustedEmailDomains, which lets the first sign-in create the account. For OIDC, akind: 'oidc'orkind: 'microsoft'connection increateOAuthLogin. See SAML and OAuth and OIDC sign-in. To send people to a page after a SAML sign-in instead of a JSON answer, givecreateSamlServiceanafterSignInpath (and optionallymfaUrlanderrorUrl); mounted withiam.useProtocol, the ACS then answers with a redirect that sets the session cookie, and?returnTo=/pathon the login URL picks the page for one sign-in. The OAuth callback still answers JSON. - Have the customer register it. Their IT team adds your new values (the SAML
entityIdandacsUrl, or the OIDCredirectUri) to their identity provider, ideally as a second application next to the old one, so both work during the dual run. - Re-point SCIM.
scim.createConnectionreturns a connection path and a token shown once. The customer replaces the old provider's SCIM base URL and token in their identity provider's provisioning settings, and the directory then creates its people in Better IAM. Map directory groups to roles withscim.setRoleMappings. See SCIM inbound.
Do not bulk-import people a SCIM directory manages: a SCIM create for an address that already exists in the tenant
fails with a 409 uniqueness error. Let the directory create them, and set trustScimProvisioned on the SSO
connection (true for any SCIM connection of the tenant, or a list of SCIM connection IDs): a person's first SSO
sign-in with a verified email (for SAML, a domain in trustedEmailDomains) then links to the account the directory
created. SSO users without a directory can be imported with a providers entry for the connection, or left for
their first SSO sign-in to create. Either way you can restrict the organization to allowedMethods: ['federated']
with tenants.setAuthPolicy as soon as its connection works; nobody has to link anything first. The exception is
the tenant's owner: trustScimProvisioned never links an owner, so link theirs with identities.linkProvider before
you turn off other methods.
Check parity
Compare counts and sample people before switching anyone:
tenants.usagereturns the tenant's identity, group, and role counts; compare them with the export.policies.effectiveActionslists what one person may do on a resource; spot-check people from each role (What can a person do).better-iam explain ACTION RESOURCE --tenant TENANT_ID --identity EMAILexplains one decision from a terminal.
Cut over with a dual run
Switch organizations one at a time while the old system keeps serving everyone else. Your application decides per organization which system signs people in, for example with a flag on your own organization record.
| Phase | The old system | Better IAM | Your application |
|---|---|---|---|
| Import | Signs everyone in | Holds imported people, roles, and groups | Unchanged |
| Dual run | Signs everyone in | Re-run the import to pick up new sign-ups; apply deactivations with identities.setStatus | Optionally captures passwords at sign-in (Capture passwords) |
| Switch an organization | Still running, no longer used by that organization | Signs that organization in; its SSO and SCIM connections are live | Routes that organization's sign-in to Better IAM; existing old sessions end when they expire or at sign-out |
| Retire | Read-only, kept until the rollback window ends | Signs everyone in | The old provider's SDK is removed |
During the dual run, make Better IAM the one place you change roles for organizations that have switched, and the old system the one place for those that have not. Changing both invites drift.
Roll back
Rolling back is cheap as long as the old system still holds everyone and nobody depends on changes made only in Better IAM:
- Point the organization back. Flip your per-organization flag. Its people sign in to the old system again, where their passwords, factors, and links still exist.
- Know what does not flow back. Accounts created in Better IAM after the switch, role changes made there, and passwords set there do not exist in the old system. No API returns a password hash, so people who joined or changed their password after the switch reset it in the old system.
- Keep the old tenant intact. Do not delete the old provider's data or connections until the rollback window ends. SSO keeps working there as long as the customer's identity provider still has the old application.
- Start the import again. To discard an import, restore the snapshot you took before it into an empty database
with
better-iam store-import, or delete the tenants you created withtenants.setStatus(status: 'deleted'). Deleted tenants are purged after the retention window bybetter-iam purge.
Errors
| Code | When |
|---|---|
IDENTITY_EXISTS | A batch contains an address that already exists in the tenant. Skip people already imported and deduplicate the export. |
TENANT_INACTIVE | The tenant is still pending: it was created without activate: true and its owner has not accepted the invitation. |
INVALID_INPUT | An empty batch or more than 100 entries, an invalid email, or an attribute that is undeclared or of the wrong type. |
INVALID_ACTION | A role names a permission that is not declared in permissions.resourceTypes. |
GRANT_AUTHORITY_REQUIRED | The caller assigns roles but holds no active grant authority in the tenant. |
ACCESS_DENIED | The caller may not create people, grant one of the roles, or fill one of the groups; or tenants.create with activate: true from a caller who is not a root principal. |
LIMIT_EXCEEDED | The tenant's plan limit on identities, roles, or groups is reached. |
DELIVERY_REQUIRED | tenants.create without activate: true, or identities.invite, without authentication.sendEmail. |
RECENT_AUTH_REQUIRED | tenants.create, identities.linkProvider, identities.requestPasswordReset, or saml.createConnection from a session older than recentAuthenticationMs. |
WEAK_PASSWORD | A password passed to identities.create or createMany breaks the password policy. |
ACCOUNT_LINK_CONFLICT | A providers entry or identities.linkProvider names an external account already linked to another identity, or a connection the identity already has a link through. |
ACCOUNT_LINK_REQUIRED | A first federated sign-in used an address that belongs to an imported account with no link for that connection. |
Next steps
Tenant trees, owners, invitations, and account linking.
Magic links and codesThe sign-in method most imported people use first.
Enterprise onboardingDomain, SSO, SCIM, and offboarding for one customer organization.
Configuration as codeRoles, groups, and bindings as one document applied to each tenant.
Better IAM is created by Sean Filimon
Last updated
Run in multiple regions
Deploy Better IAM in several regions, give each organization a home region and its own sign-in address, and send sign-in to the right region with WRONG_REGION redirects.
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.