Migrate authentication from Auth0 to self-hosted Keycloak
Move users and passwords, set up production Keycloak, and update your apps to use it. Includes staged cutover and the limits of rolling back to Auth0.
From Auth0 to Keycloak (self-hosted) · Published 2026-10-08
Before you commit
- Hands-on effort
- 2–4 weeks of hands-on work
- Elapsed time
- Allow time for the hash export, staged cutover and a 1–3 month lazy-migration window if used.
- Examples cover
- Keycloak 26.8.0 · PostgreSQL · OIDC
- Content review
- 2026-10-09
- Integration testing
- Not recorded. Rehearse the commands and rollback on staging.
Prerequisites
- Auth0 tenant administration, application owners, staging users and a secure process for password exports.
- Production Keycloak operations, managed PostgreSQL, backups, monitoring and an upgrade owner.
Don’t migrate yet if…
- You cannot operate and patch an identity service or fund the ongoing work.
- Required Auth0 Actions, MFA factors or enterprise connections have no tested replacement.
Auth0 sells self-serve plans in fixed MAU brackets. B2C Essentials costs $700 a month at 10,000 MAU and $2,100 at 30,000; above that you need an Enterprise contract. B2B Essentials costs $2,100 a month at 10,000 MAU, with 3 enterprise connections included and $100 a month for each extra one.
Keycloak has no user or connection license fees. You pay for infrastructure and the work of running it. A small setup can start with one 2-vCPU node and managed Postgres; production redundancy needs more. Compare your usage in the calculator.
The hard parts are moving passwords, changing token validation in every app, and running the service yourself. Name the person who will own upgrades and login incidents before you start.
0. Before you start
- Request the password hash export now. The Management API never returns password hashes. You have to open a support ticket, which the Free plan can’t do, and Auth0 reviews each request without giving an ETA. You’ll need an RSA 4096-bit PGP key whose email matches the tenant admin who files the ticket (requirements). MFA secrets go through the same process.
- Check your Auth0 renewal date. If you’re on annual billing, plan the cutover before it.
- List everything your tenant does: database connections, social and enterprise connections, Actions (and legacy Rules or Hooks), Organizations, roles and permissions, APIs (audiences), MFA factors, custom domain, and Universal Login customizations.
- Find every place your apps store the Auth0 user ID (
auth0|64f…,google-oauth2|1098…). Thesubclaim will change.
1. Run Keycloak in production
The examples use Keycloak 26.8.0 (released 2026-10-01). The project ships about four minor releases a year and puts security fixes only into the current minor, so plan to upgrade every quarter. The official image is quay.io/keycloak/keycloak.
Put the options in conf/keycloak.conf. db, health-enabled and metrics-enabled are build-time options, so run build once after setting them. Pass the database password as KC_DB_PASSWORD from your secret store rather than leaving it in the file:
db=postgres
db-url-host=keycloak-db.internal
db-username=keycloak
health-enabled=true
metrics-enabled=true
bin/kc.sh build
bin/kc.sh start --optimized --hostname https://auth.example.com \
--https-certificate-file=/etc/keycloak/tls.crt \
--https-certificate-key-file=/etc/keycloak/tls.key
If a load balancer terminates TLS, use --hostname https://auth.example.com --http-enabled true --proxy-headers xforwarded and don’t expose the /admin paths publicly (reverse proxy guide).
- Database: use managed PostgreSQL with point-in-time recovery. Everything lives there, including user sessions, which are stored in the database by default.
- HA: run 2 or more nodes. In production mode they form a cluster on their own through the database (
jdbc-pingstack, ports 7800 and 57800 between nodes). The load balancer should check/health/readyon the management port (9000). - Backups: database snapshots are the backup. Run a test restore before cutover.
- Upgrades: read the upgrading guide for every minor release and test it on staging. Since 26.6, patch releases within a minor can be rolled out node by node with no downtime.
2. Map the tenant to a realm
Create one realm per Auth0 tenant. For signing keys, keep RS256, the default in both.
| Auth0 | Keycloak |
|---|---|
| Tenant | Realm |
| Application | Client (public with PKCE for SPAs and mobile, confidential for server apps) |
| API (audience) + permissions | Client roles, client scopes, and an Audience protocol mapper |
| Database connection | The realm’s local users |
| Social connection | Identity provider (Google, GitHub, Apple, Microsoft, generic OIDC…) |
| Enterprise connection (SAML, OIDC, Azure AD) | SAML or OIDC identity provider |
| AD/LDAP connector | User federation (LDAP/Kerberos) |
| Rules / Actions | Authentication flows, required actions, protocol mappers, or a custom Java SPI |
| Organizations | Organizations (fully supported since Keycloak 26.0; enable per realm) |
| Roles | Realm roles or client roles, plus groups |
| MFA (OTP, WebAuthn) | OTP and WebAuthn policies, and conditional flows |
| Universal Login | Login theme |
Actions don’t port directly. Keycloak has no hosted JavaScript hooks. Claim enrichment becomes protocol mappers. Pre-login checks become authentication flow steps. Post-registration calls become an event listener SPI or a job that reads admin events. Anything else means writing a Java provider and deploying it to providers/.
Social connections: create each identity provider with the same Google or GitHub OAuth app, and add https://auth.example.com/realms/<realm>/broker/<alias>/endpoint to its allowed redirect URIs. Keep the Auth0 callback in place until rollback is off the table.
3. Export users from Auth0
curl -s -X POST "https://$AUTH0_DOMAIN/api/v2/jobs/users-exports" \
-H "Authorization: Bearer $MGMT_TOKEN" -H "Content-Type: application/json" \
-d '{"format":"json","fields":[{"name":"user_id"},{"name":"email"},{"name":"email_verified"},
{"name":"name"},{"name":"given_name"},{"name":"family_name"},{"name":"identities"},
{"name":"app_metadata"},{"name":"user_metadata"},{"name":"created_at"}]}'
# -> {"id":"job_abc123","status":"pending",...}
curl -s -H "Authorization: Bearer $MGMT_TOKEN" "https://$AUTH0_DOMAIN/api/v2/jobs/job_abc123"
When status is completed, the location URL holds a gzipped NDJSON file. That link is valid for 60 seconds, so fetch it straight away. Leave out connection_id to export every connection. CSV can’t include whole metadata objects, so use JSON.
4. Choose how passwords move
Auth0 hashes database passwords with bcrypt. Keycloak 26 hashes with Argon2 by default, also supports PBKDF2, and has no bcrypt support built in. You have three options:
| Option | How | Trade-off |
|---|---|---|
| Import hashes | Install a community bcrypt provider (for example keycloak-bcrypt) and import the hashes from the support export | Nobody notices the switch. But you depend on the export arriving, and on a third-party JAR you have to rebuild and test on every Keycloak upgrade |
| Lazy migration | A user storage SPI such as keycloak-user-migration calls a small service you write. That service checks the password with Auth0’s Resource Owner Password grant (grant_type=http://auth0.com/oauth/grant-type/password-realm, realm=Username-Password-Authentication) |
No hashes needed. Keycloak stores the password in its own format on first login. Auth0 must stay paid and online for the whole window, the Password grant must be enabled on a confidential client, and Auth0’s brute-force protection can block your service’s IP (read this first) |
| Forced reset | Import users without credentials and add the UPDATE_PASSWORD required action, or send reset emails |
Simplest to implement. Expect lost logins and support tickets |
A common combination is to import everyone, run lazy migration for 1–3 months, and then send reset emails to whoever is left.
With the bcrypt provider, a user’s credential looks like this:
{ "type": "password",
"secretData": "{\"value\":\"$2b$10$…\",\"salt\":\"\"}",
"credentialData": "{\"hashIterations\":10,\"algorithm\":\"bcrypt\"}" }
Check the field names against the provider’s source and test one user before the bulk run. Once that user logs in, add a realm password policy of hashAlgorithm(argon2), so Keycloak re-hashes each password on its next login.
5. Import users
Convert each NDJSON line to a Keycloak UserRepresentation. Store the old ID as an attribute (auth0_id). Link social users through federatedIdentities, so their next Google or GitHub login lands on the same account:
{ "username": "ana@example.org", "email": "ana@example.org", "emailVerified": true,
"enabled": true, "attributes": { "auth0_id": ["auth0|64f1…"] },
"federatedIdentities": [{ "identityProvider": "google", "userId": "1098…", "userName": "ana@example.org" }],
"credentials": [ … ] }
Load them in batches of a few thousand through the Admin REST API’s partial import:
curl -s -X POST "https://auth.example.com/admin/realms/myrealm/partialImport" \
-H "Authorization: Bearer $KC_ADMIN_TOKEN" -H "Content-Type: application/json" \
-d @users-batch-001.json # {"ifResourceExists":"SKIP","users":[...]}
For very large sets, stop all nodes and run bin/kc.sh import --file realm.json, which writes straight to the database. You can also do a partial import from the console under Realm settings → Action → Partial import.
6. Change the apps
| Auth0 | Keycloak | |
|---|---|---|
| Issuer | https://example.eu.auth0.com/ (trailing slash) |
https://auth.example.com/realms/myrealm |
| Discovery | /.well-known/openid-configuration |
/realms/myrealm/.well-known/openid-configuration |
| JWKS | /.well-known/jwks.json |
/realms/myrealm/protocol/openid-connect/certs |
| Logout | /v2/logout (or OIDC logout if enabled) |
end_session_endpoint from discovery |
sub |
auth0|64f1… |
A Keycloak UUID |
| Roles / permissions | Custom namespaced claim from an Action, permissions with RBAC |
realm_access.roles, resource_access.<client>.roles |
Access token aud |
Your API identifier | Needs an Audience mapper |
- APIs: validate tokens against the new issuer and JWKS. During cutover, accept both issuers.
- User IDs: either migrate your foreign keys to the new
sub, or add a user-attribute protocol mapper that emitsauth0_idas a claim and look users up by that. - Custom claims: recreate each namespaced claim (
https://example.com/plan) as a hardcoded, user-attribute or role mapper. Keep the claim name so your app code doesn’t change. - SDKs: replace
@auth0/auth0-spa-jsor@auth0/auth0-reactwith a standard OIDC client such as oidc-client-ts, and replace server SDKs with your framework’s OIDC library. Drop Auth0-only parameters likeaudienceandconnection. For the latter, usekc_idp_hint=<alias>to skip straight to a social provider.
7. Verify
On staging, with a copy of production users, test each path end to end:
- A bcrypt-imported user logs in, and the stored credential switches to Argon2 (Users → Credentials).
- A lazy-migration user logs in, and the federation link disappears afterwards.
- A social user lands on the existing account, not a new duplicate.
- An enterprise SSO user, an MFA user, a password reset email, and a new signup all work.
- Decode an access token and compare it claim by claim with an Auth0 token for the same user.
- Load test login at about three times your peak. Argon2 uses roughly 7 MB of memory per hash, so size the nodes for it.
8. Cut over gradually
Refresh tokens and sessions don’t carry over, so every user has to log in again. Announce it, and avoid your busiest day.
Move one app (client) at a time, internal tools first. For a single large app, route a share of new logins to Keycloak with a flag and leave existing Auth0 sessions alone until they expire. Turn on saving of login events (Realm settings → User events settings), then watch LOGIN_ERROR events under Events and your app’s error rate.
9. Rollback plan
Keep the Auth0 tenant, its callbacks, and the app config for both providers until the lazy-migration window ends. Before cutover, rehearse a rollback with a new user, a changed password, an MFA change and an enterprise login.
Pointing the apps back at Auth0 restores the old login path, not the current identity data. Users who signed up or changed their password in Keycloak are missing or stale in Auth0. Auth0’s bulk import has a custom_password_hash field, but test whether it accepts the hashes Keycloak stored before relying on it; the fallback is to import those users without passwords and send reset emails. Pause signups and profile changes on Keycloak while you reconcile, and include changed roles, profile data and identity links.
10. Clean up
- Remove the lazy-migration provider and its bridge service, then disable the Password grant on the Auth0 client.
- Send reset emails to users who never logged in during the window.
- Remove the Auth0 callback URLs from the Google, GitHub and enterprise IdP configurations, and give enterprise customers your new SAML metadata.
- Securely delete the decrypted hash export and revoke the PGP key.
- Downgrade the Auth0 subscription to Free or cancel it. After your own retention period, delete the tenant (Tenant Settings → Advanced).
Found a command or version mismatch? Report a correction with the version and steps to reproduce it. Remove secrets and customer data first.