Migrate from self-hosted Keycloak to Kevra Identity
Time: ~1 to 3 hours of active work for a typical realm · Prereqs:
- Admin access to your existing self-hosted Keycloak (any version from 19.x onward)
- Admin access to your Kevra Identity realm (already provisioned in the Kevra console)
- A maintenance window for cutover, sized to your user count and traffic patterns
What you'll set up
Your existing Keycloak realm (users, groups, roles, clients, identity providers) becomes a Kevra-managed realm. Your applications continue to authenticate users without re-registration. You stop running and patching Keycloak yourself.
How the migration works
You do this entirely yourself, using two Keycloak admin features that exist on both ends:
- Partial Export on your self-hosted realm produces a JSON file containing realm settings, clients, roles, groups, and identity providers. With the
kc.sh exportCLI you can also include users. - Partial Import on your Kevra realm reads that JSON and applies it to the existing realm.
Passwords are not exportable in plaintext, so user credentials use one of four strategies (Step 6). Application clients then point their issuer URL at the new Kevra realm endpoint (Step 7).
Step 1: Inventory your source realm
Open your self-hosted Keycloak admin console and note what you have. This is for your own reference during cutover.
- Users: total count, and roughly how many are active in the last 90 days.
- Groups and roles: any custom realm roles or composite roles your apps depend on.
- Clients: every OIDC and SAML client, with redirect URIs and any custom client scopes.
- Identity providers: any social or enterprise IdPs configured (Google, Okta, Entra, SAML federation).
- Authentication flows: any custom flows, required actions, or password policies you tuned.
- Themes and extensions: any custom themes or SPI extensions installed on your server.
The first three transfer cleanly via Partial Import. The last two need manual reapplication on the Kevra side (see Limitations).
Step 2: Export your source realm
Pick the option that matches your access level on the source server.
Option A: Admin console (no shell access needed)
- In your self-hosted Keycloak admin console, switch to your source realm.
- Go to Realm settings, click the Action menu (top right), and choose Partial export.
- In the dialog, tick Include groups and roles and Include clients. Save the JSON file locally.
- This option does not include users. You will populate users via SCIM, password reset, or federated login (Step 6).
Option B: kc.sh CLI (includes users)
Run on the host where Keycloak is installed. Stop the server first, or use --users different_files to avoid lock contention on a running instance.
This produces YOUR_REALM_NAME-realm.json containing realm config, clients, roles, groups, identity providers, and user records. User records include hashed credentials in the format Keycloak stores them (PBKDF2 by default).
If you run kc.sh export while the Keycloak server is still running on the same host, the command exits with ERROR: Unable to start the management interface on 0.0.0.0:9000 after the export finishes. This is harmless. The export file is already written before the error. The error happens because the export subcommand tries to spin up its own management port at the end, which conflicts with the running server. Confirm YOUR_REALM_NAME-realm.json exists in the export directory and proceed.
Step 3: Edit the export (required)
Open the JSON in a text editor and remove fields that conflict with the existing Kevra realm. All four edits below are required for a clean import; the first three address resources that already exist on every Kevra realm.
- Remove default Keycloak clients that already exist on the destination (
account,account-console,admin-cli,broker,realm-management,security-admin-console). Find each block under"clients": [...]and delete it. - Remove default realm roles that already exist on the destination (
offline_access,uma_authorization, and any role whose name starts withdefault-roles-). Find them under"roles": { "realm": [...] }and delete each block. - Rewrite references to
default-roles-<source-realm-name>in each user's"realmRoles"array to point at the destination realm's default role,default-roles-<destination-realm-name>. The default-roles composite carriesoffline_access,uma_authorization, and the standardaccount.*client roles, so dropping it entirely would leave imported users without any of those defaults (their tokens will be missingrealm_access,resource_access, andaud, and offline-access flows will fail). The destination realm always has its owndefault-roles-<destination-realm-name>composite, so a simple rename in the JSON keeps every user wired up the way they were on the source. - Remove the top-level
"id"field (the realm's UUID). Keeping it will cause a conflict with the existing Kevra realm. - Reset the first-login flow on each identity provider. A provider's
firstBrokerLoginFlowAliasoften points at a custom flow from the source realm (for exampleCopy of first broker login). Authentication flows do not travel with a Partial Import, so the import fails withNo available authentication flow with alias: .... The script below resets it to the built-infirst broker login. - Remove
service-account-*users. Keycloak recreates the service account for a client automatically, so importing the user record on top of it conflicts. - If you do not want to import all users, edit the
"users"array.
Save the edited JSON.
The combined edit looks like this in Python (run on the export file before import):
import json
SOURCE_REALM = "YOUR_SOURCE_REALM_NAME" # the realm you exported from
TARGET_REALM = "YOUR_KEVRA_REALM_NAME" # the realm you are importing into
DEFAULT_CLIENTS = {
"account", "account-console", "admin-cli", "broker",
"realm-management", "security-admin-console",
}
DEFAULT_REALM_ROLES = {"offline_access", "uma_authorization"}
src = json.load(open(f"{SOURCE_REALM}-realm.json"))
src.pop("id", None)
src["clients"] = [c for c in src.get("clients", []) if c["clientId"] not in DEFAULT_CLIENTS]
src["roles"]["realm"] = [
r for r in src.get("roles", {}).get("realm", [])
if r["name"] not in DEFAULT_REALM_ROLES and not r["name"].startswith("default-roles-")
]
src["roles"]["client"] = {
k: v for k, v in src.get("roles", {}).get("client", {}).items()
if k not in DEFAULT_CLIENTS
}
target_default_role = f"default-roles-{TARGET_REALM}"
for u in src.get("users", []):
if u.get("realmRoles"):
u["realmRoles"] = [
target_default_role if r.startswith("default-roles-") else r
for r in u["realmRoles"]
]
# Identity providers reference authentication flows by alias, but Partial
# Import does not carry authentication flows. An IdP that points its first-login
# flow at a custom flow (for example "Copy of first broker login") fails the
# whole import with "No available authentication flow with alias: ...". Reset
# the flow aliases to the built-in "first broker login" so the IdP imports
# cleanly; re-point them afterward only if you genuinely need a custom flow.
for idp in src.get("identityProviders", []):
idp["firstBrokerLoginFlowAlias"] = "first broker login"
idp.pop("postBrokerLoginFlowAlias", None)
# Service-account users are recreated automatically when their owning client is
# imported, so importing the user record on top of that conflicts. Drop them.
src["users"] = [
u for u in src.get("users", [])
if not u.get("username", "").startswith("service-account-")
]
json.dump(src, open(f"{SOURCE_REALM}-realm.cleaned.json", "w"), indent=2)
Step 4: Import into your Kevra realm
- Open the Kevra console at app.kevra.io and go to your Identity realm.
- Open the realm admin console (the link is on your tenant's Identity page).
- In the realm admin console, go to Realm settings, click the Action menu, and choose Partial import.
- Click Browse and upload the JSON from Step 2 (edited per Step 3).
- In the import dialog, decide what to bring in by ticking the resource categories: Users, Groups, Clients, Identity providers, Realm roles, Client roles. If you already set up inbound SCIM or an identity provider on this realm before importing (the usual case), those resources and their clients are already present. Tick only Users and Groups, and leave Clients and Identity providers unticked, so you do not re-import what is already there. Re-importing an identity provider is also the most common cause of a failed first import (see Troubleshooting).
- For the If a resource already exists option, choose:
- Fail for a first import (you want errors loud, not silent overwrites).
- Skip if you are re-running and want to leave existing resources alone. Note: with Skip, users whose records already exist will not have new group memberships or role bindings applied; the entire user record is left untouched.
- Overwrite when you are re-running after a source-side fix (for example, you added group memberships in the source after the first import) and want existing users updated to match.
- Click Import. The result panel shows counts per category and any errors.
If the import fails on a specific client or role, the error message names it. Edit the JSON to remove or rename it, then re-import. Imports are incremental, so you can run Partial Import repeatedly to layer in resources.
If you prefer the REST API or the kcadm.sh CLI over the admin console UI, the equivalent of step 6 above is to add a top-level "ifResourceExists" field to your JSON (one of "FAIL", "SKIP", or "OVERWRITE") before posting it to POST /admin/realms/YOUR_REALM_NAME/partialImport. Same semantics as the radio button in the UI.
Step 5: Rebuild outbound SCIM mappings (skip if you do not push to a downstream)
Skip this step if your realm does not propagate user and group changes outbound to a downstream application. If your old self-hosted Keycloak was pushing to a Spring app, an in-house service, or any other SCIM 2.0 server, read on.
The realm import in Step 4 brought your users and groups across with their existing SCIM externalId attribute intact. What it did not bring across is the internal mapping table the outbound SCIM client uses to know which downstream resource id corresponds to each Kevra-side user or group. Without that mapping, the first time a group membership changes after the import, the outbound PATCH silently drops, because the client cannot resolve the remote id. Only user-level updates (User PUT) reach the downstream, group memberships do not.
Rebuild the mapping table once:
- Open your tenant in the Kevra console.
- Scroll to Outbound SCIM and confirm it is Enabled with the right downstream endpoint.
- Click Rebuild mappings and confirm the dialog.
- The panel returns a summary: how many users and groups were matched, how many were skipped because they have no
externalId, and how many mappings already existed.
This step is idempotent. Run it whenever the imported data drifts from the downstream (for example, after a re-import).
If a user or group on Kevra has no externalId attribute, it is either a purely local resource that only existed in your source Keycloak and not in the downstream, or it predates your SCIM provisioning. Resources without an externalId will not propagate outbound. Decide per resource whether to:
- Leave it local. It will not appear on the downstream.
- Provision it on the downstream manually, then re-run Rebuild mappings to pick up the link.
- Delete it if it is unused legacy data.
The rebuild assumes the downstream uses the SCIM externalId as its resource id (the common pattern with Goldfish-SDK-based SCIM servers and most Spring-based implementations). If your downstream issues opaque ids of its own that differ from the externalId, contact us so we can add the corresponding lookup mode.
Step 6: Decide how user passwords get into Kevra
Pick the option that matches how your source authenticates today. You can combine more than one (for example, federated for most users, password reset for a few service accounts).
Local Keycloak passwords (PBKDF2 hashes)
If your source Keycloak version is the same major version as the Kevra-hosted version, hashed credentials in the export are usable directly. Users keep logging in with their existing passwords, no reset needed. The Kevra console's realm overview page shows the running Keycloak version so you can compare.
Federated login (Google, Okta, Entra, etc.)
Recreate the identity providers in your Kevra realm. The Partial Import in Step 4 covers this if you ticked Identity providers. Verify by going to Identity providers in the realm admin console, opening each one, and confirming the client secret is set (Keycloak does not export secrets; you will need to paste them in from your IdP application config).
Users continue to log in via their existing IdP without ever entering a password to Kevra. This is the cleanest migration path.
Forced password reset on first login
If the source export does not include hashes, or hashes are not portable, mark all imported users with the Update Password required action. In the realm admin console:
- Go to Users, select the users (or use Add filter for bulk).
- For each, open Required user actions and add Update Password.
They are emailed a reset link the next time they sign in. Use this as a fallback; it adds friction.
SCIM-managed users
If your IdP already speaks SCIM, enable inbound SCIM on your Kevra realm and let the IdP push the current user state.
Enable inbound SCIM on your Kevra realm before running the Partial Import. Doing so declares the externalId attribute on the realm's User Profile, which is what lets Keycloak persist the upstream IdP identifier (Okta's 00u... id, Entra's object id, etc.) on each user record. If you import users first and enable SCIM afterwards, Keycloak 26 silently drops the externalId attribute on the already-imported records: the users themselves are present, but their externalId is empty until the IdP re-pushes them.
With SCIM inbound enabled, you have two paths in Step 4:
- Import users from the export. Each user record carries its
externalIdattribute (the value your IdP set during inbound provisioning on the source realm), so memberships and downstream SCIM clients keyed by that identifier keep resolving without any IdP re-push. - Skip users (untick Users in the import dialog) and let your IdP re-provision them through SCIM after cutover. Slower, but produces a clean inbound-driven state.
Step 7: Repoint your applications
Each application client in your realm has an issuer URL. Self-hosted, it looked like:
After migration, the new issuer is:
Find your Kevra realm's domain on the Identity page in the Kevra console.
For each application:
- Update its OIDC discovery URL or SAML metadata URL to the new issuer.
- If the application caches JWKS keys, restart it or wait for the cache to refresh (usually 10 to 60 minutes).
- Test login end to end with a real user account.
If many applications hard-code the old hostname and you cannot reconfigure them quickly, a temporary DNS-level cutover is possible: point your old hostname (keycloak.your-company.com) at the Kevra realm via a CNAME and matching TLS certificate so the issuer URL stays stable. This is set up via the Kevra console's custom domain feature on your realm.
Step 8: Cut over and decommission
- At the start of your cutover window, freeze user changes on the source (no new users, no profile edits).
- If you used Option B in Step 2 (CLI export with users), re-export the source realm to capture any last-minute changes since Step 2, then re-run Partial Import in Step 4 with the Skip strategy for already-imported resources and Fail for users to add only the new ones.
- Switch your applications to the new issuer.
- Run smoke tests with real users from at least two roles.
- Keep the source Keycloak running, read-only, for one to two weeks as a rollback safety net.
- After the safety window, decommission the source.
Verify it worked
- Real users from two or more roles can log in to at least three of your applications.
- Profile updates made in your Kevra realm are visible to your apps within a normal token-refresh cycle.
- Group memberships and role-based access decisions match the source behavior. (Spot-check at least one user per group in the realm admin console under Users > select user > Groups.)
- For federated users: the IdP login button on the Kevra realm works and lands them in the right app.
A quick automated smoke test for password portability, runnable from any shell:
curl -s -X POST "https://YOUR_KEVRA_DOMAIN/realms/YOUR_REALM_NAME/protocol/openid-connect/token" \
-d "client_id=admin-cli&grant_type=password&username=ALICE_USERNAME&password=ALICE_PASSWORD" \
| jq -r '.access_token // .error_description'
A returned access token confirms the imported PBKDF2 hash is usable on the Kevra side. An error like "Account is not fully set up" is expected for any user whose source record carried a requiredActions entry (for example, VERIFY_EMAIL); those users complete the action via the browser login flow on first sign-in.
Known limitations
- Custom SPI extensions are not portable. If your source ran custom Java extensions (custom authenticators, event listeners, user storage providers), they cannot be installed on the Kevra-hosted instance. Plan an alternative (often a stock Keycloak feature covers the same need).
- Custom themes that ship as JAR resources cannot be installed. Realm-level theming via the standard Keycloak realm settings (logo, colors, locale) is supported.
- Source versions older than 19.x require an intermediate upgrade before export. Keycloak's own release notes document the supported upgrade paths.
- Identity provider client secrets are not exported. After importing IdP config, you must paste each secret in manually from the IdP's application config page.
- Partial Import has a size limit. For realms with tens of thousands of users, split the export into multiple smaller files (edit the
"users"array down) and import in batches.
Troubleshooting
Import fails with "Resource already exists"
A client, role, or group with the same name was already in your Kevra realm (often a default like account). Either remove it from the JSON (Step 3) or re-run the import with the Skip strategy.
Import fails with "unknown_error", and the server log shows "No available authentication flow with alias: ..."
An identity provider in the export points its first-login flow at a custom authentication flow that exists only in the source realm, and Partial Import does not carry authentication flows. The whole import stops, even though your users and groups are fine. Leave Identity providers unticked in the import dialog (the provider is normally already on your Kevra realm from the pre-migration setup), or let the Step 3 script reset the provider's firstBrokerLoginFlowAlias to the built-in first broker login before you import.
Import fails with "Invalid argument" or schema error Source realm is on an older Keycloak version than the destination, and a field was renamed or removed. Open the JSON, find the offending field (the error message names it), and either delete it or rename it to the new key name from the Keycloak release notes.
Users imported but cannot log in Most often, source hashes are in a credential format the destination Keycloak rejects. Fall back to the forced password reset or SCIM-managed flow in Step 6.
Application gets invalid_token after cutover
The application is still hitting the old issuer URL or has cached the old JWKS. Confirm the OIDC discovery URL is updated and restart the app to flush JWKS.
IdP login button shows "invalid_client" The client secret was not carried over by the export. Open the IdP configuration in the realm admin console, paste the secret from your IdP application config, and save.
After import, tokens have no realm_access / resource_access / aud, and offline_access login fails with "Offline tokens not allowed for the user or client"
The Step 3 cleanup script stripped (rather than renamed) the default-roles-<source-realm> reference on each user's realmRoles, so imported users have no realm-default role assigned and therefore no offline_access, no account.* client roles, no audience. Two ways to fix:
- Re-run a fresh import with the corrected Step 3 script that renames
default-roles-<source-realm>todefault-roles-<destination-realm>instead of stripping it. -
Or bulk-assign the destination's default role to existing users without re-importing, via
kcadm:kcadm.sh get users -r YOUR_REALM_NAME --fields id -q max=1000 \ | jq -r '.[].id' \ | while read uid; do kcadm.sh add-roles -r YOUR_REALM_NAME --uid "$uid" --rolename "default-roles-YOUR_REALM_NAME" doneRun from a host that has the
kcadm.shCLI (or from your Kevra realm admin console under Users with a bulk role assignment).
Import fails with "No available authentication flow with alias: ..."
Your source realm has a custom authentication flow that the identity provider points at via firstBrokerLoginFlowAlias (or postBrokerLoginFlowAlias). The Kevra realm only has the Keycloak default flows, so the IdP cannot be created and the whole import fails with a generic unknown_error in the UI; the server log shows the offending alias. Two ways out:
- Easiest: open the JSON, find the
identityProvidersentry, and changefirstBrokerLoginFlowAliasto"first broker login"(the default). RemovepostBrokerLoginFlowAliasentirely if it points at a non-default flow, or set it to the matching default. Save and re-import. - If you need to keep the custom flow: pre-create it on the Kevra realm under Authentication > Flows > Create flow with the same name, before re-running the import. The executions inside the custom flow need to be recreated by hand; partial import does not bring authentication flows over.
Next steps
- Configure inbound SCIM provisioning so your IdP keeps user lifecycle in sync going forward.
- Push users from Kevra to a downstream app (outbound SCIM) if any of your apps consume SCIM directly.