Migrate from Keycloak
Move a Keycloak realm's users, groups, and realm roles to a Better IAM tenant from a realm export with groups.create, roles.create, bindings.create, and identities.createMany, then re-establish passwords, MFA, and brokered sign-in.
This guide moves one Keycloak realm to a Better IAM tenant. Keycloak is a standalone identity server that your applications reach over OpenID Connect or SAML; Better IAM runs inside your Node.js application and answers authorization questions directly. The move copies the realm's people, groups, and realm roles from a realm export, then has people prove who they are again, because Keycloak's password hashes, OTP secrets, and WebAuthn credentials cannot be imported. You will:
- map realms, users, groups, roles, clients, and identity providers to Better IAM concepts;
- export the realm with Keycloak's
kc.sh export; - create a tenant for the realm, and recreate its realm roles (with composites) and groups (with their role mappings);
- import users 100 at a time with
identities.createMany, with their roles, groups, and declared attributes; - re-establish passwords, MFA, and brokered sign-in, and move applications off Keycloak tokens.
Run the guide once per realm. The master realm holds Keycloak's own administrators; it maps to Better IAM's root
tenant, whose administrator you create with bootstrap, so you do not import it.
How the concepts map
| Keycloak | Better IAM | Notes |
|---|---|---|
| Realm | Tenant (type: 'organization') | Created with tenants.create. One realm per tenant. |
master realm administrators | Root tenant administrators | Created with bootstrap; not imported. |
| User | Identity (kind: 'user') | Email is required and is the sign-in name; Better IAM has no separate username. |
firstName, lastName | name | One display name. |
| User attributes | Identity attributes declared in permissions.identityAttributes | Single values of type string, number, or boolean. |
enabled: false | identities.setStatus with status: 'disabled' | |
| Password credential | Password on the identity | Not imported; see Passwords. |
| OTP and WebAuthn credentials | MFA and passkeys | Not imported; people enroll again. |
Required actions (CONFIGURE_TOTP, UPDATE_PASSWORD) | Tenant policy (requireMfa) and reset links | |
| Realm role | Role (roles.create) | Keycloak roles are names; a Better IAM role also says which actions it allows. |
| Composite role | Role with inherits | Up to 20 direct parents; no cycles. |
| Client role | A role, or actions in permissions | Better IAM roles belong to the tenant, not to a client. |
| Group (nested) | Group (flat), with role bindings | Subgroups become their own groups that also receive their parents' roles. For a hierarchy people manage, use teams. |
Default roles (default-roles-{realm}) | An automatic assignment rule | |
| OIDC client | A client of the OAuth/OIDC provider | Only if other applications still need tokens from Better IAM. |
| Identity provider (brokering) | OAuth connection or SAML connection | |
| User federation (LDAP, Kerberos) | Not supported as a sign-in source | Better IAM does not bind against an external directory. Use SCIM or SAML from the directory's IdP. The LDAP gateway is the reverse: it serves Better IAM's directory to LDAP clients. |
| Authentication flows | Tenant authentication policy | allowedMethods, requireMfa, session lifetimes. |
| Realm password policy | Tenant password rules | minPasswordLength, passwordMinClasses, passwordHistory, passwordMaxAgeDays. |
| Sessions and offline tokens | Sessions | Not imported. Everyone signs in once after the switch. |
| Service account of a client | Service account and API keys | serviceAccounts.create, then credentials.create. |
| Admin events and login events | Audit events | History stays in Keycloak; Better IAM records from the switch on. |
What carries over
| Data | Carries over | How people continue |
|---|---|---|
| Email, name, enabled state, declared attributes | Yes | Nothing to do. |
| Realm role and group memberships | Yes | Nothing to do. |
| Keycloak user ID | Yes, as the legacyId attribute | Your applications' tables keep a mapping. |
| Users without an email | No | createMany needs an email; add one in Keycloak first, or leave them out. |
| Password | No | Magic link, or a reset link from an administrator. |
| OTP and WebAuthn | No | Enroll again; enforce with requireMfa. |
Links to brokered identity providers (federatedIdentities) | Yes, as providers on each imported person | Sign in through the same provider, connected directly to Better IAM. |
| Email verification | Yes, from emailVerified | Addresses Keycloak had not verified are verified by the first magic link. |
Prerequisites
- Shell access to a Keycloak server (the Quarkus distribution, version 17 or later) that can run
kc.sh exportagainst the realm's database. Older distributions export throughstandalone.shsystem properties instead. - 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 and reset links. - A list of what each realm role and client role allows in your applications. Keycloak stores only role names; Better IAM roles carry actions.
Migrate
Export the realm
Export the realm with its users into one JSON file. Run it on a stopped server, or on a separate node that uses the same database.
bin/kc.sh export --file acme-realm.json --realm acme --users realm_file--users realm_file writes the users into the realm file itself. Check bin/kc.sh export --help on your version
for the exact options.
The file contains the realm's roles.realm, groups (with nested subGroups), and users. Each user has
username, email, emailVerified, firstName, lastName, enabled, attributes, realmRoles, groups (as
paths such as /engineering/backend), credentials, and federatedIdentities (each with the identity provider's
alias as identityProvider and the person's ID there as userId). The file also holds password hashes and client
secrets: keep it on an encrypted disk and delete it after the move.
You can read the same data from the Keycloak Admin REST API (GET /admin/realms/{realm}/users, /groups, /roles)
if you cannot run an export. Adjust the loader in the next steps.
Configure Better IAM
Declare an attribute for the Keycloak user ID and for each Keycloak user attribute you keep, turn on magic links, and declare the actions your roles grant.
import { betterIam } from 'better-iam';
import { postgresAdapter } from 'better-iam/adapter-postgres';
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,
sendEmail: async (message) => sendMail(message),
},
permissions: {
identityAttributes: {
legacyId: 'string', // the Keycloak user ID
department: 'string', // a Keycloak user attribute you keep
},
resourceTypes: {
invoice: { actions: ['invoices:read', 'invoices:approve'] },
report: { actions: ['reports:read', 'reports:export'] },
},
},
});Create the tenant
tenants.create needs a root sign-in from the last five minutes. With activate: true, which only a root principal
may pass, the tenant is active at once and the realm's administrator becomes its owner without an invitation, so
you can import members right away. The owner signs in with a magic link, a reset link, or a provider you link to
them.
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>import { iam } from '../../iam';
const admin = { token: process.env.BETTER_IAM_TOKEN! };
const { tenant } = await iam.api.tenants.create(admin, {
parentId: process.env.ROOT_TENANT_ID!,
type: 'organization',
name: 'Acme',
slug: 'acme', // the realm name works well as the sign-in alias
ownerEmail: 'it-admin@acme.example',
activate: true, // active now; the owner's account is created without an invitation
ownerName: 'Acme IT',
ownerEmailVerified: true, // only if Keycloak marked this address verified
});
console.log('IAM_TENANT_ID =', tenant.id);Route: POST /tenants/create
Without activate, the owner receives an emailed invitation instead, and the tenant stays pending (no members can
be created) until they accept it.
Recreate roles and groups
Keycloak roles carry no permissions of their own, so you map each role name to the actions it should allow. The
script creates every realm role (Keycloak's built-in defaults excepted), sets inherits for composite roles, creates
one group per group path, and binds each group, and each of its subgroups, to the group's realm roles. It saves the
name-to-ID maps for the user import.
import { readFile, writeFile } from 'node:fs/promises';
import { iam } from '../../iam';
type KeycloakRole = { name: string; description?: string; composite?: boolean; composites?: { realm?: string[] } };
type KeycloakGroup = { name: string; path: string; realmRoles?: string[]; subGroups?: KeycloakGroup[] };
type Realm = { realm: string; roles?: { realm?: KeycloakRole[] }; groups?: KeycloakGroup[] };
const realm = JSON.parse(await readFile('acme-realm.json', 'utf8')) as Realm;
const admin = { token: process.env.BETTER_IAM_TOKEN! };
const tenantId = process.env.IAM_TENANT_ID!;
/** What each Keycloak role allows in Better IAM. Roles not listed are created without permissions. */
const permissions: Record<string, string[]> = {
accountant: ['invoices:read'],
approver: ['invoices:read', 'invoices:approve'],
analyst: ['reports:read', 'reports:export'],
};
const skipped = new Set(['offline_access', 'uma_authorization', `default-roles-${realm.realm}`]);
// Roles first, then inheritance once every role exists.
const roleIds: Record<string, string> = {};
const keycloakRoles = (realm.roles?.realm ?? []).filter((role) => !skipped.has(role.name));
for (const role of keycloakRoles) {
const created = await iam.api.roles.create(admin, {
tenantId,
name: role.name,
...(role.description ? { description: role.description } : {}),
...(permissions[role.name] ? { permissions: permissions[role.name] } : {}),
});
roleIds[role.name] = created.id;
}
for (const role of keycloakRoles) {
const parents = (role.composites?.realm ?? []).filter((name) => roleIds[name]).map((name) => roleIds[name]!);
if (role.composite && parents.length)
await iam.api.roles.update(admin, { tenantId, roleId: roleIds[role.name]!, inherits: parents });
}
// Groups: one Better IAM group per path; members of a subgroup also get the roles of every group above it.
const groupIds: Record<string, string> = {};
async function createGroup(group: KeycloakGroup, inherited: string[]): Promise<void> {
const roles = [...new Set([...inherited, ...(group.realmRoles ?? [])])].filter((name) => roleIds[name]);
const created = await iam.api.groups.create(admin, {
tenantId,
name: group.path.replace(/^\//, ''),
description: `Keycloak group ${group.path}`,
});
groupIds[group.path] = created.id;
for (const name of roles)
await iam.api.bindings.create(admin, {
tenantId,
roleId: roleIds[name]!,
subjectType: 'group',
subjectId: created.id,
});
for (const child of group.subGroups ?? []) await createGroup(child, roles);
}
for (const group of realm.groups ?? []) await createGroup(group, []);
await writeFile('acme-ids.json', JSON.stringify({ roleIds, groupIds }, null, 2));Route: POST /roles/create, POST /roles/update, POST /groups/create, POST /bindings/create
A role created without permissions grants nothing until you add them with roles.update; its bindings and
inheritance are in place already. Client roles are not in roles.realm: model each one as a realm-level role
here (for example billing-app:approver), or fold its meaning into the actions of an existing role.
Import users
The script creates users 100 at a time with their realm roles, groups, legacyId, declared attributes, verified
email status, and links to the identity providers Keycloak brokered for them, then disables the ones Keycloak had
disabled. Keycloak attributes are lists; the script keeps the first value.
import { readFile, writeFile } from 'node:fs/promises';
import { iam } from '../../iam';
type KeycloakUser = {
id: string;
username: string;
email?: string;
emailVerified?: boolean;
firstName?: string;
lastName?: string;
enabled: boolean;
attributes?: Record<string, string[]>;
realmRoles?: string[];
groups?: string[];
federatedIdentities?: Array<{ identityProvider: string; userId: string }>;
};
// Keycloak identity provider alias -> the Better IAM connection that replaces it (see "Brokered sign-in" below).
// providerId is the connection's id; issuer is the provider's `iss`, exactly.
const BROKERED: Record<string, { providerId: string; issuer: string }> = {
google: { providerId: 'acme-google', issuer: 'https://accounts.google.com' },
'acme-entra': {
providerId: 'acme-entra',
issuer: `https://login.microsoftonline.com/${process.env.ACME_ENTRA_TENANT_ID}/v2.0`,
},
};
const realm = JSON.parse(await readFile('acme-realm.json', 'utf8')) as { users?: KeycloakUser[] };
const { roleIds, groupIds } = JSON.parse(await readFile('acme-ids.json', 'utf8')) as {
roleIds: Record<string, string>;
groupIds: Record<string, string>;
};
const admin = { token: process.env.BETTER_IAM_TOKEN! };
const tenantId = process.env.IAM_TENANT_ID!;
const ownerEmail = 'it-admin@acme.example'; // already in the tenant: tenants.create created them
// Resume support: people already imported are kept in acme-users.json.
const mapping: Record<string, string> = JSON.parse(await readFile('acme-users.json', 'utf8').catch(() => '{}'));
const users = (realm.users ?? []).filter(
(user) =>
user.email &&
user.email.toLowerCase() !== ownerEmail &&
!user.username.startsWith('service-account-') &&
!mapping[user.id],
);
const withoutEmail = (realm.users ?? []).filter((user) => !user.email).map((user) => user.username);
if (withoutEmail.length) console.warn('Skipped, no email:', withoutEmail.join(', '));
for (let start = 0; start < users.length; start += 100) {
const batch = users.slice(start, start + 100);
const { identities } = await iam.api.identities.createMany(admin, {
tenantId,
identities: batch.map((user) => ({
email: user.email!,
name: [user.firstName, user.lastName].filter(Boolean).join(' ') || user.username,
emailVerified: user.emailVerified === true,
providers: (user.federatedIdentities ?? []).flatMap((link) =>
BROKERED[link.identityProvider] ? [{ ...BROKERED[link.identityProvider]!, subject: link.userId }] : [],
),
attributes: {
legacyId: user.id,
...(user.attributes?.department?.[0] ? { department: user.attributes.department[0] } : {}),
},
roleIds: (user.realmRoles ?? []).filter((name) => roleIds[name]).map((name) => roleIds[name]!),
groupIds: (user.groups ?? []).filter((path) => groupIds[path]).map((path) => groupIds[path]!),
})),
});
// identities[i] replaces batch[i].
for (const [i, identity] of identities.entries()) mapping[batch[i]!.id] = identity.id;
await writeFile('acme-users.json', JSON.stringify(mapping, null, 2));
console.log(`${start + batch.length}/${users.length}`);
}
// Disable everyone Keycloak had disabled, including those imported by an earlier run (repeating it is harmless).
for (const user of realm.users ?? [])
if (!user.enabled && mapping[user.id])
await iam.api.identities.setStatus(admin, { tenantId, identityId: mapping[user.id]!, status: 'disabled' });Route: POST /identities/createMany, POST /identities/setStatus
- Each batch is one transaction. If any entry fails, nobody in it is created; fix the entry and rerun, and the script
skips users already in
acme-users.json. identities.setStatusneeds a sign-in from the last five minutes. If the run outlasts that and stops withRECENT_AUTH_REQUIRED, runloginagain, export the new token, and rerun the script.- The owner is skipped because
tenants.createalready created them. If they are in the export, add their Keycloak user ID and Better IAM identity ID toacme-users.jsonby hand, and link their brokered sign-in withiam.api.identities.linkProvider(admin, { tenantId, identityId, providerId, issuer, subject }). - Brokered identities whose alias has no entry in
BROKEREDget no link; those people sign in with a magic link. Each KeycloakuserIdmust be the provider's subject exactly: Google'ssub, the OIDCsubfor an OIDC provider, or the NameID for a SAML provider (Keycloak's default principal). Two users linked to the same external account fail the batch withACCOUNT_LINK_CONFLICT. - Keycloak can hold two users with the same email when the realm allows duplicate emails; Better IAM cannot
(
IDENTITY_EXISTS). Merge or rename them in Keycloak first. service-account-*users belong to Keycloak clients. Recreate them as service accounts instead.
Let people in
Imported identities are active and have no password; their email is verified where Keycloak had verified it.
Passwords
Better IAM does not import password hashes. Keycloak stores hashes in its own credential format, Better IAM stores
only Argon2id hashes it computed itself, and no API accepts a precomputed hash; see
Importing passwords from another provider.
People continue with a magic link (passwordlessEmail is on), which also verifies their address, or with a reset
link that an administrator sends with identities.requestPasswordReset(admin, { tenantId, identityId }). That call
needs a sign-in from the last five minutes, so send reset links in short runs.
MFA
OTP secrets and WebAuthn credentials stay in Keycloak. If the realm required OTP, require MFA in the tenant policy so
everyone enrolls an authenticator at their next sign-in. setAuthPolicy replaces the whole policy, so include every
rule you want:
import { iam } from '../../iam';
const admin = { token: process.env.BETTER_IAM_TOKEN! };
await iam.api.tenants.setAuthPolicy(admin, {
tenantId: process.env.IAM_TENANT_ID!,
authPolicy: {
requireMfa: true,
minPasswordLength: 14, // carry over the realm's password policy
passwordHistory: 5,
},
});Route: POST /tenants/setAuthPolicy
Brokered sign-in
People who signed in through an identity provider brokered by Keycloak (Google, a customer's Entra ID, a SAML IdP)
sign in through the same provider directly in Better IAM. Add an OAuth connection
or SAML connection for each one, with the id and issuer you put in BROKERED:
import { createOAuthLogin } from 'better-iam/oauth';
import { iam } from './iam';
iam.useProtocol(
createOAuthLogin({
...iam.protocolHost,
connections: [
{
id: 'acme-google', // BROKERED.google.providerId
tenantId: process.env.IAM_TENANT_ID!,
kind: 'google',
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: 'https://app.acme.example/oauth/acme-google/callback',
},
{
id: 'acme-entra', // BROKERED['acme-entra'].providerId
tenantId: process.env.IAM_TENANT_ID!,
kind: 'microsoft',
microsoftTenant: process.env.ACME_ENTRA_TENANT_ID!,
clientId: process.env.ACME_ENTRA_CLIENT_ID!,
clientSecret: process.env.ACME_ENTRA_CLIENT_SECRET!,
redirectUri: 'https://app.acme.example/oauth/acme-entra/callback',
},
],
}),
);The import linked each person's brokered identity to these connections, so their first sign-in through one finds
their imported account, with its roles and groups. For a SAML IdP, create the connection with
saml.createConnection before the import and use its providerId and idpIssuer in BROKERED. People who were
not imported are created at their first sign-in, with a verified email.
Move applications off Keycloak tokens
Applications that validated Keycloak access tokens and read realm_access.roles change in one of two ways:
-
Applications in the same Node.js process or behind the same API ask Better IAM directly. Replace role-name checks with decisions on actions:
routes/invoices.ts import { iam } from '../iam'; export async function approveInvoice(request: Request, invoice: { id: string; tenantId: string }) { await iam.require({ headers: request.headers, tenantId: invoice.tenantId, action: 'invoices:approve', resource: { type: 'invoice', id: invoice.id }, }); // ...approve } -
Separate applications that need OpenID Connect tokens become clients of Better IAM's OAuth/OIDC provider. Register one client per Keycloak client and update the application's issuer, client ID, and secret. Claims differ from Keycloak's, so read roles from authorization decisions rather than token claims.
Cut over
- Freeze changes in the Keycloak realm (new users, role and group edits).
- Export again and run
03-users.ts: it imports only users missing fromacme-users.json. Recreate any roles or groups added since the first run by hand, or rerun02-roles-groups.tsagainst a fresh tenant. - Switch applications to Better IAM and announce the change: everyone signs in once, with a magic link or through their provider.
- Keep Keycloak running read-only until you no longer need its history, then delete the export files.
Alternative: federate to Keycloak during the transition
If you cannot ask everyone to sign in again on one day, keep Keycloak as the sign-in provider for a while. Add the realm as an OpenID Connect connection; people sign in with their Keycloak password and land in the account you imported for them.
import { createOAuthLogin } from 'better-iam/oauth';
import { iam } from './iam';
iam.useProtocol(
createOAuthLogin({
...iam.protocolHost,
connections: [
{
id: 'acme-keycloak',
tenantId: process.env.IAM_TENANT_ID!,
kind: 'oidc',
issuer: 'https://keycloak.acme.example/realms/acme',
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
redirectUri: 'https://app.acme.example/oauth/keycloak/callback',
// Every value must match its declared type: leave a missing claim out rather than setting it to undefined.
mapAttributes: (claims) =>
typeof claims.department === 'string' ? { department: claims.department } : {},
},
],
}),
);Keycloak's ID tokens carry the Keycloak user ID as sub, so link every imported person to this connection in
03-users.ts, next to their brokered identities:
providers: [
{ providerId: 'acme-keycloak', issuer: 'https://keycloak.acme.example/realms/acme', subject: user.id },
// ...the entries from BROKERED
],Trade-offs of this path:
- Imported people keep the roles and groups the import gave them. Without the
acme-keycloaklink, an imported person's first sign-in through it fails withACCOUNT_LINK_REQUIRED; people who were not imported are created at their first sign-in from the verifiedemailclaim, without roles or groups (grant those with automatic assignment rules on the mapped attributes). - Keycloak's issuer and every discovered endpoint must use HTTPS.
- Keycloak keeps owning passwords and MFA until you turn the connection off. To finish the move, people set a Better IAM credential (a reset link or a passkey) while they can still sign in, and you then remove the connection.
Verify it works
iam.api.tenants.usage(admin, { tenantId })returnsidentities,groups, androlescounts. Compare them with the export: users with an email (withoutservice-account-*users, plus the owner if they are not in the export), group paths, and realm roles minus the built-in defaults, plus the tenant's built-in Owner role.- For a few people, compare their Keycloak role mappings with
policies.effectiveActionsin Better IAM. - Sign in as a test user with a magic link; with
requireMfa, confirm the sign-in asks to enroll an authenticator. - Disabled Keycloak users are
disabledinidentities.list(admin, { tenantId, status: 'disabled' }).
Troubleshooting
Next steps
Permissions, inheritance, and group bindings.
Tenant authentication policyRequired MFA, allowed methods, and password rules per tenant.
OAuth and OIDC sign-inConnections for the identity providers Keycloak brokered.
OAuth/OIDC providerIssue tokens to applications that used Keycloak clients.
Teams and departmentsNested teams when flat groups are not enough.
Migration guidesMoving from other identity providers.
Better IAM is created by Sean Filimon
Last updated
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.
Upgrade guides
How to upgrade a Better IAM deployment between releases: the version policy, the release list, the standard upgrade procedure, rolling back, and the changes from 0.1 to 0.2.