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.
This guide moves an application from Better Auth to Better IAM. Both are libraries that run inside your Node.js server and store data in your database, so the move is a data import plus a code change, with no hosted service in between. What moves is who people are, which organization they belong to, and what role they hold there. Credentials do not move: Better IAM cannot read Better Auth's password hashes, two-factor secrets, or passkeys, so people prove who they are once more after the switch. You will:
- map Better Auth's tables and plugins to Better IAM concepts;
- recreate each Better Auth organization as a Better IAM tenant, with an Admin and a Member role;
- import members 100 at a time with
identities.createMany, keeping the Better Auth user ID as an attribute, their verified email status, and their Google and GitHub links; - let password users in with magic links;
- replace the Better Auth handler, server calls, and client calls in a Next.js app, then cut over.
How the concepts map
| Better Auth | Better IAM | Notes |
|---|---|---|
betterAuth({ ... }) instance | betterIam({ ... }) instance | Both are created once in your server code. |
user table | Identity (kind: 'user') in a tenant | Every identity belongs to exactly one tenant. |
account row with providerId: 'credential' | Password on the identity | Hashes are not imported; see Passwords. |
account row for google, github, and others | An OAuth connection per provider and tenant, and a link created at import from accountId | See Social sign-in. |
session table | Session | Not imported. Everyone signs in once after the switch. |
verification table | Short-lived challenges | Not imported; pending links and codes expire. |
Organization plugin: organization | Tenant with type: 'organization' | Created with tenants.create; the slug carries over. |
Organization plugin: member | Identity in that tenant, plus a role binding | A person in three organizations becomes three identities, one per tenant. |
member.role owner | The tenant's owner (identities.setOwner) | The built-in Owner role is protected and granted through ownership, not bindings. |
member.role admin, member, custom roles | Roles from roles.create, bound at import | You define each role's permissions. |
Organization plugin: invitation (pending) | identities.invite | Send new invitations; Better Auth invitation IDs do not carry over. |
Organization plugin: team | Teams (teams.create, teams.addMembers) or groups | Optional. |
| Access control plugin statements | permissions.actions or permissions.resourceTypes in the configuration | Actions such as projects:write, used in roles and policies. |
Admin plugin: user.banned | identities.setStatus with status: 'disabled' | |
Two-factor plugin: twoFactor | MFA | Secrets are not imported; people enroll again. Enforce with the tenant policy's requireMfa. |
Passkey plugin: passkey | Passkeys | Not imported; people register again. |
| API key plugin | Service accounts and API keys | Issue new keys with credentials.create. |
| Magic link plugin | Magic links and codes (passwordlessEmail: true) | Same idea; turn it on before you switch. |
Better IAM has no equivalent of Better Auth's active organization on one session. A Better IAM session belongs to one tenant; a person who belongs to several organizations signs in to each, and can switch between them with account linking.
What carries over
| Data | Carries over | How people continue |
|---|---|---|
| Name, email, organization memberships, roles | Yes, by this import | Nothing to do. |
| Better Auth user ID | Yes, as the legacyId attribute and in a mapping table | Your own tables keep working through the mapping. |
| Password | No | Sign in with a magic link, or set a password through a reset link. |
| Google, GitHub, and other social links | Yes, for each provider you recreate as a connection | Sign in through the provider, as before. |
| Email verification status | Yes, from user."emailVerified" | Unverified addresses are verified by the first magic link sign-in. |
| Two-factor secrets, backup codes, passkeys | No | Enroll again after the first sign-in. |
| Sessions | No | Everyone signs in once after the switch. |
Prerequisites
- Read access to the Better Auth database. The examples use PostgreSQL through the
pgpackage and Better Auth's default table and column names (user,account,organization,member,invitation, with camelCase columns such as"userId"). If you changedmodelNameorfieldsin your Better Auth configuration, adjust the SQL. - Better IAM installed, migrated, and bootstrapped, following Installation and
Quickstart. You need the root tenant's ID from
bootstrap. - An email transport in
authentication.sendEmail, for magic links, reset links, and invitations. - A TypeScript runner for the scripts, such as
npx tsx.
Migrate
Count what you are moving
Run these queries against the Better Auth database and keep the numbers: you compare them with Better IAM at the end.
SELECT count(*) AS users FROM "user";
SELECT "providerId", count(*) AS accounts FROM "account" GROUP BY "providerId" ORDER BY 2 DESC;
SELECT count(*) AS organizations FROM "organization";
SELECT "role", count(*) AS members FROM "member" GROUP BY "role" ORDER BY 2 DESC;
SELECT count(*) AS pending_invitations FROM "invitation" WHERE "status" = 'pending';The providerId counts tell you how people sign in today: credential is email and password, and every other value
is a social provider you will configure in Better IAM. The role values are the roles you will create; a member can
hold several, stored comma-separated.
Configure Better IAM for the move
Declare an identity attribute for the Better Auth user ID, turn on magic links, declare the actions your roles grant, and add one OAuth connection per social provider and organization.
import { betterIam } from 'better-iam';
import { postgresAdapter } from 'better-iam/adapter-postgres';
import { createOAuthLogin } from 'better-iam/oauth';
import { sendMail } 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: {
passwordlessEmail: true, // magic links and emailed codes, for people who used a password
sendEmail: async (message) => sendMail(message),
},
permissions: {
// Keeps the Better Auth user ID on each identity, readable in policies as principal.legacyId.
identityAttributes: { legacyId: 'string' },
resourceTypes: {
project: { actions: ['projects:read', 'projects:write', 'projects:delete'] },
},
},
});
// One connection per provider and organization: every connection belongs to one tenant. createOAuthLogin refuses a
// connection without a tenant ID, so the connection is added only once the tenant exists (next step).
const acmeTenantId = process.env.ACME_TENANT_ID;
if (acmeTenantId)
iam.useProtocol(
createOAuthLogin({
...iam.protocolHost,
basePath: '/api/iam/oauth/login',
trustedOrigins: ['https://app.example.com'],
connections: [
{
id: 'acme-google',
tenantId: acmeTenantId,
kind: 'google',
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: 'https://app.example.com/api/iam/oauth/acme-google/callback',
},
],
}),
);You can reuse the Google and GitHub OAuth apps you registered for Better Auth: add the new callback URLs to their
allowed redirect URIs. Connections need a tenant ID, so set ACME_TENANT_ID (and add the other connections) after
the next step has created the tenants.
Recreate the organizations
Sign in to the CLI as the root administrator and hand its token to the scripts. tenants.create needs a sign-in from
the last five minutes, so run this script right after login.
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 ROOT_TENANT_ID=<root-tenant-id>
export BETTER_AUTH_DATABASE_URL=postgres://...The script creates one active tenant per Better Auth organization, with its first owner's account, and records which tenant replaced which organization in a table it creates in the Better Auth database.
import pg from 'pg';
import { iam } from '../../iam';
const source = new pg.Pool({ connectionString: process.env.BETTER_AUTH_DATABASE_URL });
const admin = { token: process.env.BETTER_IAM_TOKEN! };
const rootTenantId = process.env.ROOT_TENANT_ID!;
await source.query(`
CREATE TABLE IF NOT EXISTS iam_migration_tenants (
organization_id text PRIMARY KEY,
tenant_id text NOT NULL,
owner_email text NOT NULL
)`);
const { rows: organizations } = await source.query<{
id: string;
name: string;
slug: string | null;
ownerEmail: string | null;
ownerName: string | null;
ownerEmailVerified: boolean | null;
}>(`
SELECT o.id, o.name, o.slug,
owner.email AS "ownerEmail", owner.name AS "ownerName", owner."emailVerified" AS "ownerEmailVerified"
FROM "organization" o
LEFT JOIN LATERAL (
SELECT u.email, u.name, u."emailVerified" FROM "member" m JOIN "user" u ON u.id = m."userId"
WHERE m."organizationId" = o.id AND 'owner' = ANY(string_to_array(m.role, ','))
ORDER BY m."createdAt" LIMIT 1) owner ON true
WHERE NOT EXISTS (SELECT 1 FROM iam_migration_tenants t WHERE t.organization_id = o.id)
ORDER BY o."createdAt"`);
for (const organization of organizations) {
if (!organization.ownerEmail) {
console.warn(`Skipped ${organization.name}: no owner`);
continue;
}
const { tenant } = await iam.api.tenants.create(admin, {
parentId: rootTenantId,
type: 'organization',
name: organization.name,
// Better IAM slugs are 1-63 lowercase letters, digits, or hyphens, and unique (SLUG_TAKEN otherwise).
...(organization.slug ? { slug: organization.slug.toLowerCase() } : {}),
ownerEmail: organization.ownerEmail,
activate: true, // active now; the owner's account is created without an invitation
...(organization.ownerName?.trim() ? { ownerName: organization.ownerName.trim() } : {}),
ownerEmailVerified: organization.ownerEmailVerified === true,
});
await source.query(
'INSERT INTO iam_migration_tenants (organization_id, tenant_id, owner_email) VALUES ($1, $2, $3)',
[organization.id, tenant.id, organization.ownerEmail],
);
console.log(`${organization.name} -> ${tenant.id}`);
}
await source.end();Route: POST /tenants/create
activate: true, which only a root principal may pass, makes each tenant active at once and creates its owner
without a password or an invitation, so members can be imported right away. Owners sign in like everyone else: with
a magic link, a reset link, or Google once the next step links it. Without activate, the owner receives an emailed
invitation instead, the tenant stays pending until they accept it with tenants.acceptInvitation, and members
cannot be created before that (TENANT_INACTIVE).
If your app does not use the organization plugin, create one tenant for the whole app the same way, with yourself or a product administrator as its owner, and import every user into it.
Import members
For each tenant, the script creates the Admin and Member roles (skipping ones that exist), then creates the
organization's members in batches of 100 with their role, their Better Auth user ID as legacyId, their verified
email status, links to their Google and GitHub accounts, and no password.
import pg from 'pg';
import { iam } from '../../iam';
type CredentialInput = Parameters<typeof iam.api.roles.list>[0];
type Link = { providerId: string; issuer: string };
const source = new pg.Pool({ connectionString: process.env.BETTER_AUTH_DATABASE_URL });
const admin: CredentialInput = { token: process.env.BETTER_IAM_TOKEN! };
/**
* Better Auth account.providerId -> the OAuth connection that replaces it, per tenant (the connections in iam.ts).
* providerId is the connection's id; issuer is the provider's `iss`, exactly. account.accountId is the subject:
* Google's `sub`, or the numeric GitHub user ID.
*/
const connections: Record<string, Record<string, Link>> = {
[process.env.ACME_TENANT_ID!]: {
google: { providerId: 'acme-google', issuer: 'https://accounts.google.com' },
github: { providerId: 'acme-github', issuer: 'https://github.com' }, // a kind: 'github' connection
},
};
const roleDefinitions = [
{
name: 'Admin',
description: 'Manages projects and members',
permissions: [
'projects:read',
'projects:write',
'projects:delete',
'iam:identities:create',
'iam:identities:read',
'iam:bindings:create',
'iam:bindings:read',
'iam:bindings:delete',
'iam:roles:read',
],
},
{ name: 'Member', description: 'Reads and edits projects', permissions: ['projects:read', 'projects:write'] },
];
/** Better Auth role name -> Better IAM role name. `owner` is handled by ownership, not a role binding. */
const roleNames: Record<string, string> = { admin: 'Admin', member: 'Member' };
async function ensureRoles(tenantId: string): Promise<Map<string, string>> {
const existing = await iam.api.roles.list(admin, { tenantId });
const ids = new Map(existing.map((role) => [role.name, role.id]));
for (const definition of roleDefinitions)
if (!ids.has(definition.name)) {
const role = await iam.api.roles.create(admin, { tenantId, ...definition });
ids.set(role.name, role.id);
}
return ids;
}
await source.query(`
CREATE TABLE IF NOT EXISTS iam_migration_users (
legacy_user_id text NOT NULL,
tenant_id text NOT NULL,
identity_id text NOT NULL,
PRIMARY KEY (legacy_user_id, tenant_id)
)`);
const { rows: tenants } = await source.query<{
organization_id: string;
tenant_id: string;
owner_email: string;
}>('SELECT organization_id, tenant_id, owner_email FROM iam_migration_tenants');
for (const { organization_id, tenant_id: tenantId, owner_email } of tenants) {
const tenant = await iam.api.tenants.get(admin, { tenantId });
const roles = await ensureRoles(tenantId);
const { rows: members } = await source.query<{
id: string;
email: string;
name: string | null;
emailVerified: boolean;
role: string;
}>(
`SELECT u.id, u.email, u.name, u."emailVerified", m.role
FROM "member" m JOIN "user" u ON u.id = m."userId"
WHERE m."organizationId" = $1
AND NOT EXISTS (SELECT 1 FROM iam_migration_users x
WHERE x.legacy_user_id = u.id AND x.tenant_id = $2)
ORDER BY u.id`,
[organization_id, tenantId],
);
// Each member's social accounts that have a connection in this tenant become links ('credential' never does).
const { rows: accounts } = await source.query<{ userId: string; providerId: string; accountId: string }>(
'SELECT "userId", "providerId", "accountId" FROM "account" WHERE "userId" = ANY($1)',
[members.map((member) => member.id)],
);
const links = new Map<string, Array<Link & { subject: string }>>();
for (const account of accounts) {
const target = connections[tenantId]?.[account.providerId];
if (target)
links.set(account.userId, [...(links.get(account.userId) ?? []), { ...target, subject: account.accountId }]);
}
// The owner already exists: tenants.create created them. Record their mapping, link their providers, skip them.
const owner = members.find((member) => member.email.toLowerCase() === owner_email.toLowerCase());
if (owner) {
const [identity] = await iam.api.identities.list(admin, { tenantId, query: owner_email, limit: 1 });
if (identity) {
await iam.api.identities.update(admin, { tenantId, identityId: identity.id, attributes: { legacyId: owner.id } });
for (const link of links.get(owner.id) ?? [])
await iam.api.identities.linkProvider(admin, { tenantId, identityId: identity.id, ...link });
await source.query(
'INSERT INTO iam_migration_users (legacy_user_id, tenant_id, identity_id) VALUES ($1, $2, $3)',
[owner.id, tenantId, identity.id],
);
}
}
const pending = members.filter((member) => member !== owner);
for (let start = 0; start < pending.length; start += 100) {
const batch = pending.slice(start, start + 100);
const { identities } = await iam.api.identities.createMany(admin, {
tenantId,
identities: batch.map((member) => ({
email: member.email,
name: member.name?.trim() || member.email.split('@')[0]!,
emailVerified: member.emailVerified === true,
providers: links.get(member.id) ?? [],
attributes: { legacyId: member.id },
roleIds: member.role
.split(',')
.map((name) => roles.get(roleNames[name.trim()] ?? ''))
.filter((id): id is string => id !== undefined),
})),
});
// identities[i] replaces batch[i].
for (const [i, identity] of identities.entries())
await source.query(
'INSERT INTO iam_migration_users (legacy_user_id, tenant_id, identity_id) VALUES ($1, $2, $3)',
[batch[i]!.id, tenantId, identity.id],
);
console.log(`${tenant.name}: ${start + batch.length}/${pending.length}`);
}
}
await source.end();Route: POST /identities/createMany
createManycreates up to 100 identities in one transaction. If any entry fails, nobody in that batch is created, so the script can stop and run again: it skips people already iniam_migration_users.- An address already present in the tenant fails the batch with
IDENTITY_EXISTS. A tenant at its member limit fails withLIMIT_EXCEEDED. - A member whose role is only
ownergets no role binding here; make them an owner in the next step. - Members with custom Better Auth roles: add an entry to
roleDefinitionsandroleNamesfor each. - Add a
connectionsentry for every tenant and provider you configured iniam.ts. Accounts whose provider has no entry get no link, and those people sign in with a magic link. Linking the owner withidentities.linkProviderneeds a sign-in from the last five minutes.
Restore owners and disabled accounts
Better Auth organizations can have several owners. identities.setOwner makes an identity an owner and needs a
sign-in from the last five minutes, so sign in again before running this script. It also disables people the admin
plugin banned, if you use it.
import pg from 'pg';
import { iam } from '../../iam';
const source = new pg.Pool({ connectionString: process.env.BETTER_AUTH_DATABASE_URL });
const admin = { token: process.env.BETTER_IAM_TOKEN! };
const { rows: owners } = await source.query<{ tenant_id: string; identity_id: string }>(`
SELECT x.tenant_id, x.identity_id
FROM iam_migration_users x
JOIN iam_migration_tenants t ON t.tenant_id = x.tenant_id
JOIN "member" m ON m."userId" = x.legacy_user_id AND m."organizationId" = t.organization_id
JOIN "user" u ON u.id = x.legacy_user_id
WHERE 'owner' = ANY(string_to_array(m.role, ',')) AND lower(u.email) <> lower(t.owner_email)`);
for (const owner of owners)
await iam.api.identities.setOwner(admin, {
tenantId: owner.tenant_id,
identityId: owner.identity_id,
owner: true,
});
// Admin plugin only: remove this query if your "user" table has no "banned" column.
const { rows: banned } = await source.query<{ tenant_id: string; identity_id: string }>(`
SELECT x.tenant_id, x.identity_id
FROM iam_migration_users x JOIN "user" u ON u.id = x.legacy_user_id
WHERE u.banned = true`);
for (const person of banned)
await iam.api.identities.setStatus(admin, {
tenantId: person.tenant_id,
identityId: person.identity_id,
status: 'disabled',
});
await source.end();Route: POST /identities/setOwner, POST /identities/setStatus
Re-send pending invitations
Pending Better Auth invitations cannot be redeemed in Better IAM. Send a new one for each, with the matching role.
import pg from 'pg';
import { iam } from '../../iam';
const source = new pg.Pool({ connectionString: process.env.BETTER_AUTH_DATABASE_URL });
const admin = { token: process.env.BETTER_IAM_TOKEN! };
const roleNames: Record<string, string> = { admin: 'Admin', member: 'Member' };
const { rows: invitations } = await source.query<{ tenant_id: string; email: string; role: string | null }>(`
SELECT t.tenant_id, i.email, i.role
FROM "invitation" i JOIN iam_migration_tenants t ON t.organization_id = i."organizationId"
WHERE i.status = 'pending' AND i."expiresAt" > now()`);
for (const invitation of invitations) {
const roles = await iam.api.roles.list(admin, { tenantId: invitation.tenant_id });
const role = roles.find((candidate) => candidate.name === roleNames[invitation.role ?? 'member']);
await iam.api.identities.invite(admin, {
tenantId: invitation.tenant_id,
email: invitation.email,
roleIds: role ? [role.id] : [],
});
}
await iam.auth.dispatchOutbox();
await source.end();Route: POST /identities/invite
Let people in
Imported identities are active and have no password; their email is verified where Better Auth had verified it.
Passwords
Better IAM does not import password hashes. Better Auth hashes passwords with its own algorithm and format, Better IAM only stores Argon2id hashes it computed itself, and no API accepts a precomputed hash. See Importing passwords from another provider. People who used a password continue in one of two ways:
- Magic link (recommended). With
passwordlessEmail: true, the sign-in page asks for the email address and sends a link. An emailed sign-in also verifies an unverified address. Someone who wants a password again then requests a reset link with the publicauth.requestPasswordReset, which works once the address is verified (auth.changePasswordneeds a current password, which imported people do not have). - Reset link from an administrator.
identities.requestPasswordResetemails a reset link to one person. It needs a sign-in from the last five minutes, so send these in short runs right afterlogin.
import pg from 'pg';
import { iam } from '../../iam';
const source = new pg.Pool({ connectionString: process.env.BETTER_AUTH_DATABASE_URL });
const admin = { token: process.env.BETTER_IAM_TOKEN! };
// People who signed in with a password in Better Auth.
const { rows } = await source.query<{ tenant_id: string; identity_id: string }>(`
SELECT x.tenant_id, x.identity_id
FROM iam_migration_users x
WHERE EXISTS (SELECT 1 FROM "account" a
WHERE a."userId" = x.legacy_user_id AND a."providerId" = 'credential')
LIMIT 200`);
for (const row of rows)
await iam.api.identities.requestPasswordReset(admin, { tenantId: row.tenant_id, identityId: row.identity_id });
await iam.auth.dispatchOutbox();
await source.end();Route: POST /identities/requestPasswordReset
The public "forgot password" form (auth.requestPasswordReset) sends nothing to an unverified address, so a person
imported with an unverified address cannot reset a password on their own until they have signed in once with a
magic link.
Social sign-in
Better IAM keys an external account by tenant, connection, issuer, and subject, and never matches accounts by email.
The import created those keys from each account row: the connection from connections, the provider's issuer, and
accountId as the subject. A member who used Google in Better Auth therefore signs in with Google at
/api/iam/oauth/login/acme-google and lands in their imported account, with their role and legacyId, with no
magic link and no linking step.
To link a provider for someone after the import, for example a provider you add later, call
identities.linkProvider(admin, { tenantId, identityId, providerId, issuer, subject }); it needs a sign-in from the
last five minutes. People can also link a provider themselves while signed in; see
Link an existing account.
Replace Better Auth in the app
Swap the route handler, the server session reads, and the client calls. The examples use Next.js; see Integrations for other frameworks.
| Better Auth | Better IAM |
|---|---|
app/api/auth/[...all]/route.ts with toNextJsHandler(auth) | app/api/iam/[...path]/route.ts with iamNext.handlers() |
auth.api.getSession({ headers }) | iamNext.getSession() in Next.js, or iam.api.auth.getSession({ headers }) |
createAuthClient() | createIamClient<typeof iam>() from better-iam/client |
authClient.signIn.email({ email, password }) | client.auth.signIn({ tenantId, email, password }) |
authClient.signIn.magicLink({ email }) | client.auth.startPasswordless({ tenantId, destination, channel: 'email', kind: 'magic-link' }) |
authClient.signIn.social({ provider: 'google' }) | Navigate to /api/iam/oauth/login/{connectionId} |
authClient.signOut() | client.auth.signOut() |
authClient.useSession() | useSession() from better-iam/react |
authClient.organization.setActive(...) | Sign in to the organization's tenant; switch with links.switch |
Access control hasPermission | iam.require({ headers, tenantId, action, resource }), or can() in Next.js |
Every Better IAM sign-in names a tenant. Resolve it from the organization slug in the URL with tenants.lookup.
import { createIamNext } from 'better-iam/next';
import { iam } from '../iam';
export const iamNext = createIamNext(iam, { loginPath: '/login' });See Next.js for the other options.
import { iamNext } from '@/lib/iam-next';
export const runtime = 'nodejs';
const handlers = iamNext.handlers();
export const POST = handlers.POST;
export const OPTIONS = handlers.OPTIONS;
// The OAuth callback answers with JSON and sets the session cookie; send the browser on to the app instead.
const callbackPath = /^\/api\/iam\/oauth\/[^/]+\/callback$/;
export async function GET(request: Request): Promise<Response> {
const response = await handlers.GET(request);
const cookies = response.headers.getSetCookie();
const signedIn = cookies.some((cookie) => cookie.includes('better-iam.session='));
if (!callbackPath.test(new URL(request.url).pathname) || !response.ok || !signedIn) return response;
const headers = new Headers({ location: '/', 'cache-control': 'no-store' });
for (const cookie of cookies) headers.append('set-cookie', cookie);
return new Response(null, { status: 303, headers });
}import { iamNext } from '@/lib/iam-next';
export default async function OrganizationHome() {
const session = await iamNext.requireSession();
return <h1>Signed in as {session.identity.name}</h1>;
}Route: POST /tenants/lookup, POST /auth/startPasswordless, POST /auth/signIn
Your magic-link page redeems the link with auth.finishPasswordless({ tenantId, destination, token }); see
Magic links and codes. Your own tables that reference Better Auth user IDs
join through iam_migration_users (or read principal.legacyId in policies) until you rewrite those foreign keys
to Better IAM identity IDs.
Cut over
- Stop new sign-ups and organization changes in the Better Auth app (for example, put the sign-up and organization settings pages in maintenance).
- Run
02-members.tsand04-invitations.tsagain. They skip everything already imported, so this run picks up people who joined since the first run. - Deploy the app with Better IAM and remove the Better Auth handler. Better Auth sessions stop working, so everyone signs in once.
- Announce the change: password users sign in with a magic link, and social users sign in through their provider as before.
- Keep the Better Auth database read-only until you have rewritten every foreign key that points at its user IDs.
Verify it works
-
Compare counts:
iam.api.tenants.usage(admin, { tenantId })returnsidentitiesfor each tenant. It should equal the organization's Better Auth member count. -
Spot-check a few people's access with
policies.effectiveActions:scripts/migration/check.ts import { iam } from '../../iam'; const admin = { token: process.env.BETTER_IAM_TOKEN! }; const result = await iam.api.policies.effectiveActions(admin, { tenantId: process.env.ACME_TENANT_ID!, identityId: process.argv[2]!, resource: { type: 'project', id: 'any' }, }); console.log(result); -
Sign in as a test member with a magic link and confirm they land in the right organization with the right role.
-
Sign in through Google as a member who used Google in Better Auth: they land in their imported account, and
identities.listProvidersshows the link the import created.
Troubleshooting
Next steps
Passwordless sign-in pages and their options.
OAuth and OIDC sign-inConnections, first sign-in, and account linking.
Build a multi-tenant B2B SaaSOrganizations, roles, and invitations in a Next.js app.
Tenants and identitiesWhy every identity belongs to one tenant.
Email and passwordPassword policy, hashing, and resets.
Migration guidesMoving from other identity providers.
Better IAM is created by Sean Filimon
Last updated
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.
Migrate from Auth.js
Move users from an Auth.js (NextAuth.js) database to one Better IAM tenant with identities.createMany, carry over their Google and GitHub links and verified emails, and replace NextAuth in a Next.js app.