BetterIAM
Migration

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.

better-iam@better-iam/server@better-iam/oauth@better-iam/next@better-iam/clientidentities.tstenants.tsfederation.tsbase.tsaccount.tsclient.tsindex.tstenancy-and-limits.mdxb2b-saas.mdx

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 AuthBetter IAMNotes
betterAuth({ ... }) instancebetterIam({ ... }) instanceBoth are created once in your server code.
user tableIdentity (kind: 'user') in a tenantEvery identity belongs to exactly one tenant.
account row with providerId: 'credential'Password on the identityHashes are not imported; see Passwords.
account row for google, github, and othersAn OAuth connection per provider and tenant, and a link created at import from accountIdSee Social sign-in.
session tableSessionNot imported. Everyone signs in once after the switch.
verification tableShort-lived challengesNot imported; pending links and codes expire.
Organization plugin: organizationTenant with type: 'organization'Created with tenants.create; the slug carries over.
Organization plugin: memberIdentity in that tenant, plus a role bindingA person in three organizations becomes three identities, one per tenant.
member.role ownerThe tenant's owner (identities.setOwner)The built-in Owner role is protected and granted through ownership, not bindings.
member.role admin, member, custom rolesRoles from roles.create, bound at importYou define each role's permissions.
Organization plugin: invitation (pending)identities.inviteSend new invitations; Better Auth invitation IDs do not carry over.
Organization plugin: teamTeams (teams.create, teams.addMembers) or groupsOptional.
Access control plugin statementspermissions.actions or permissions.resourceTypes in the configurationActions such as projects:write, used in roles and policies.
Admin plugin: user.bannedidentities.setStatus with status: 'disabled'
Two-factor plugin: twoFactorMFASecrets are not imported; people enroll again. Enforce with the tenant policy's requireMfa.
Passkey plugin: passkeyPasskeysNot imported; people register again.
API key pluginService accounts and API keysIssue new keys with credentials.create.
Magic link pluginMagic 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

DataCarries overHow people continue
Name, email, organization memberships, rolesYes, by this importNothing to do.
Better Auth user IDYes, as the legacyId attribute and in a mapping tableYour own tables keep working through the mapping.
PasswordNoSign in with a magic link, or set a password through a reset link.
Google, GitHub, and other social linksYes, for each provider you recreate as a connectionSign in through the provider, as before.
Email verification statusYes, from user."emailVerified"Unverified addresses are verified by the first magic link sign-in.
Two-factor secrets, backup codes, passkeysNoEnroll again after the first sign-in.
SessionsNoEveryone signs in once after the switch.

Prerequisites

  • Read access to the Better Auth database. The examples use PostgreSQL through the pg package and Better Auth's default table and column names (user, account, organization, member, invitation, with camelCase columns such as "userId"). If you changed modelName or fields in 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.

inventory.sql
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.

iam.ts
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.

scripts/migration/01-organizations.ts
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.

scripts/migration/02-members.ts
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

  • createMany creates 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 in iam_migration_users.
  • An address already present in the tenant fails the batch with IDENTITY_EXISTS. A tenant at its member limit fails with LIMIT_EXCEEDED.
  • A member whose role is only owner gets no role binding here; make them an owner in the next step.
  • Members with custom Better Auth roles: add an entry to roleDefinitions and roleNames for each.
  • Add a connections entry for every tenant and provider you configured in iam.ts. Accounts whose provider has no entry get no link, and those people sign in with a magic link. Linking the owner with identities.linkProvider needs 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.

scripts/migration/03-owners.ts
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.

scripts/migration/04-invitations.ts
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 public auth.requestPasswordReset, which works once the address is verified (auth.changePassword needs a current password, which imported people do not have).
  • Reset link from an administrator. identities.requestPasswordReset emails a reset link to one person. It needs a sign-in from the last five minutes, so send these in short runs right after login.
scripts/migration/05-reset-links.ts
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 AuthBetter 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 hasPermissioniam.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.

lib/iam-next.ts
import { createIamNext } from 'better-iam/next';
import { iam } from '../iam';

export const iamNext = createIamNext(iam, { loginPath: '/login' });

See Next.js for the other options.

app/api/iam/[...path]/route.ts
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 });
}
app/[org]/page.tsx
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

  1. Stop new sign-ups and organization changes in the Better Auth app (for example, put the sign-up and organization settings pages in maintenance).
  2. Run 02-members.ts and 04-invitations.ts again. They skip everything already imported, so this run picks up people who joined since the first run.
  3. Deploy the app with Better IAM and remove the Better Auth handler. Better Auth sessions stop working, so everyone signs in once.
  4. Announce the change: password users sign in with a magic link, and social users sign in through their provider as before.
  5. Keep the Better Auth database read-only until you have rewritten every foreign key that points at its user IDs.

Verify it works

  1. Compare counts: iam.api.tenants.usage(admin, { tenantId }) returns identities for each tenant. It should equal the organization's Better Auth member count.

  2. 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);
  3. Sign in as a test member with a magic link and confirm they land in the right organization with the right role.

  4. Sign in through Google as a member who used Google in Better Auth: they land in their imported account, and identities.listProviders shows the link the import created.

Troubleshooting

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page