How to Migrate from Okta to Keycloak
Last updated: March 2026
Migrating from Okta to Keycloak is a project that organizations undertake for several reasons: rising per-user costs at scale, vendor lock-in concerns, the need for deeper customization, or a strategic move toward open-source infrastructure. Whatever the motivation, the migration requires careful planning. You are moving the foundation of every authenticated interaction in your organization.
This guide covers the complete migration process: exporting users and groups from Okta, mapping applications and authentication policies to Keycloak equivalents, handling MFA enrollment, and executing a phased rollout that minimizes risk.
Migration Overview
A successful Okta-to-Keycloak migration follows these phases:
- Audit — inventory everything in Okta (users, apps, policies, integrations)
- Map — translate Okta concepts to Keycloak equivalents
- Configure — set up Keycloak with matching clients, roles, and flows
- Migrate users — export from Okta, import into Keycloak
- Migrate applications — update each application’s OIDC/SAML configuration
- Test — validate every authentication flow
- Cutover — phased rollout with rollback capability
Phase 1: Okta Audit
Before writing any migration code, document everything in your Okta tenant.
Export Application Inventory
# Get an Okta API token from Admin > Security > API > Tokens
OKTA_DOMAIN="your-org.okta.com"
OKTA_TOKEN="your-api-token"
# List all applications
curl -s -X GET
"https://$OKTA_DOMAIN/api/v1/apps?limit=200"
-H "Authorization: SSWS $OKTA_TOKEN"
-H "Accept: application/json"
| jq '[.[] | {
id: .id,
name: .name,
label: .label,
signOnMode: .signOnMode,
status: .status,
protocol: (if .signOnMode == "SAML_2_0" then "SAML" elif .signOnMode == "OPENID_CONNECT" then "OIDC" else .signOnMode end),
created: .created
}]' > okta-apps.json
echo "Exported $(jq length okta-apps.json) applications"
Export User Count and Groups
# Count total users
curl -s -X GET
"https://$OKTA_DOMAIN/api/v1/users?limit=1"
-H "Authorization: SSWS $OKTA_TOKEN"
-D - -o /dev/null 2>/dev/null
| grep -i x-total-count
# Export all groups
curl -s -X GET
"https://$OKTA_DOMAIN/api/v1/groups?limit=200"
-H "Authorization: SSWS $OKTA_TOKEN"
| jq '[.[] | {
id: .id,
name: .profile.name,
description: .profile.description,
type: .type
}]' > okta-groups.json
echo "Exported $(jq length okta-groups.json) groups"
Export Authentication Policies
# List sign-on policies
curl -s -X GET
"https://$OKTA_DOMAIN/api/v1/policies?type=OKTA_SIGN_ON"
-H "Authorization: SSWS $OKTA_TOKEN"
| jq '.' > okta-sign-on-policies.json
# List MFA enrollment policies
curl -s -X GET
"https://$OKTA_DOMAIN/api/v1/policies?type=MFA_ENROLL"
-H "Authorization: SSWS $OKTA_TOKEN"
| jq '.' > okta-mfa-policies.json
Phase 2: Concept Mapping
Okta and Keycloak use different terminology for similar concepts:
| Okta | Keycloak | Notes |
|---|---|---|
| Organization | Realm | Top-level tenant |
| Application | Client | OIDC or SAML app registration |
| Groups | Groups | Direct equivalent |
| App groups | Client roles | Per-application permissions |
| Authorization Server | Realm (built-in) | Keycloak acts as its own auth server |
| Sign-On Policy | Authentication Flow | Keycloak flows are more flexible |
| MFA Enrollment Policy | Required Actions + Auth Flow | Different mechanism, same result |
| Network Zones | Browser Flow conditions | Conditional access via auth flow |
| Okta Expression Language | Mappers | Protocol mappers for claim transformation |
| User Types | User attributes | Custom attributes in Keycloak |
| Identity Providers | Identity Providers | Direct equivalent |
For Keycloak-specific concepts, see our documentation on identity providers, RBAC, and single sign-on.
Phase 3: Keycloak Configuration
Realm Setup
Set up your Keycloak realm with settings that match your Okta configuration. Use our Keycloak Config Generator for a starting point, or configure manually:
# Create realm via Admin REST API
KEYCLOAK_URL="http://localhost:8080"
KC_TOKEN=$(curl -s -X POST
"$KEYCLOAK_URL/realms/master/protocol/openid-connect/token"
-H "Content-Type: application/x-www-form-urlencoded"
-d "grant_type=password&client_id=admin-cli&username=admin&password=admin"
| jq -r '.access_token')
curl -s -X POST
"$KEYCLOAK_URL/admin/realms"
-H "Authorization: Bearer $KC_TOKEN"
-H "Content-Type: application/json"
-d '{
"realm": "production",
"enabled": true,
"sslRequired": "external",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"editUsernameAllowed": false,
"bruteForceProtected": true,
"permanentLockout": false,
"maxFailureWaitSeconds": 900,
"failureFactor": 5,
"accessTokenLifespan": 300,
"ssoSessionIdleTimeout": 1800,
"ssoSessionMaxLifespan": 36000,
"rememberMe": true,
"loginTheme": "keycloak"
}'
For production hardening, see 8 default configurations to adjust on your Keycloak cluster and securing your Keycloak master realm.
Migrate Groups
# migrate_groups.py
import json
import requests
KEYCLOAK_URL = "http://localhost:8080"
REALM = "production"
KC_TOKEN = "your-admin-token"
# Load Okta groups
with open("okta-groups.json") as f:
okta_groups = json.load(f)
headers = {
"Authorization": f"Bearer {KC_TOKEN}",
"Content-Type": "application/json",
}
created = 0
skipped = 0
for group in okta_groups:
# Skip Okta built-in groups
if group["type"] == "BUILT_IN":
skipped += 1
continue
payload = {
"name": group["name"],
"attributes": {
"okta_id": [group["id"]],
"description": [group.get("description", "")],
},
}
response = requests.post(
f"{KEYCLOAK_URL}/admin/realms/{REALM}/groups",
headers=headers,
json=payload,
)
if response.status_code == 201:
created += 1
print(f"Created group: {group['name']}")
elif response.status_code == 409:
skipped += 1
print(f"Group already exists: {group['name']}")
else:
print(
f"Failed to create group {group['name']}: "
f"{response.status_code} {response.text}"
)
print(f"nGroups created: {created}, skipped: {skipped}")
Migrate OIDC Applications
# migrate_oidc_apps.py
import json
import requests
KEYCLOAK_URL = "http://localhost:8080"
REALM = "production"
KC_TOKEN = "your-admin-token"
OKTA_DOMAIN = "your-org.okta.com"
OKTA_TOKEN = "your-okta-token"
headers_kc = {
"Authorization": f"Bearer {KC_TOKEN}",
"Content-Type": "application/json",
}
headers_okta = {
"Authorization": f"SSWS {OKTA_TOKEN}",
"Accept": "application/json",
}
# Load apps inventory
with open("okta-apps.json") as f:
apps = json.load(f)
oidc_apps = [
a for a in apps
if a["protocol"] == "OIDC" and a["status"] == "ACTIVE"
]
for app in oidc_apps:
# Get full app details from Okta
app_detail = requests.get(
f"https://{OKTA_DOMAIN}/api/v1/apps/{app['id']}",
headers=headers_okta,
).json()
settings = app_detail.get("settings", {}).get(
"oauthClient", {}
)
# Map Okta settings to Keycloak client
redirect_uris = settings.get("redirect_uris", [])
post_logout_uris = settings.get(
"post_logout_redirect_uris", []
)
grant_types = settings.get("grant_types", [])
# Determine if confidential or public client
is_confidential = (
app_detail.get("credentials", {})
.get("oauthClient", {})
.get("token_endpoint_auth_method")
!= "none"
)
keycloak_client = {
"clientId": app_detail.get("label", "")
.lower()
.replace(" ", "-"),
"name": app_detail.get("label", ""),
"enabled": True,
"protocol": "openid-connect",
"publicClient": not is_confidential,
"clientAuthenticatorType": (
"client-secret" if is_confidential else None
),
"redirectUris": redirect_uris,
"postLogoutRedirectUris": post_logout_uris or ["*"],
"webOrigins": ["+"],
"standardFlowEnabled": (
"authorization_code" in grant_types
),
"directAccessGrantsEnabled": (
"password" in grant_types
),
"serviceAccountsEnabled": (
"client_credentials" in grant_types
),
"implicitFlowEnabled": (
"implicit" in grant_types
),
"attributes": {
"okta_app_id": app["id"],
"migration_date": "2026-04-25",
},
}
response = requests.post(
f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients",
headers=headers_kc,
json=keycloak_client,
)
if response.status_code == 201:
print(f"Created client: {keycloak_client['clientId']}")
# If confidential, retrieve the generated secret
if is_confidential:
# Get the client UUID
clients = requests.get(
f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients"
f"?clientId={keycloak_client['clientId']}",
headers=headers_kc,
).json()
if clients:
client_uuid = clients[0]["id"]
secret_resp = requests.get(
f"{KEYCLOAK_URL}/admin/realms/{REALM}"
f"/clients/{client_uuid}/client-secret",
headers=headers_kc,
).json()
print(
f" Secret: {secret_resp.get('value', 'N/A')}"
)
else:
print(
f"Failed: {keycloak_client['clientId']} "
f"- {response.status_code}"
)
Migrate SAML Applications
For SAML applications, you need to export the metadata from Okta and create corresponding SAML clients in Keycloak:
# migrate_saml_apps.py
import json
import requests
# ... (same setup as above)
saml_apps = [
a for a in apps
if a["protocol"] == "SAML" and a["status"] == "ACTIVE"
]
for app in saml_apps:
app_detail = requests.get(
f"https://{OKTA_DOMAIN}/api/v1/apps/{app['id']}",
headers=headers_okta,
).json()
saml_settings = app_detail.get("settings", {}).get(
"signOn", {}
)
keycloak_client = {
"clientId": saml_settings.get("audience", app["label"]),
"name": app["label"],
"enabled": True,
"protocol": "saml",
"frontchannelLogout": True,
"attributes": {
"saml.assertion.signature": "true",
"saml.server.signature": "true",
"saml_assertion_consumer_url_post": saml_settings.get(
"ssoAcsUrl", ""
),
"saml_single_logout_service_url_post": saml_settings.get(
"slo", ""
),
"saml.signing.certificate": "", # Set after creation
"saml_name_id_format": saml_settings.get(
"subjectNameIdFormat",
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
),
},
"redirectUris": [
saml_settings.get("ssoAcsUrl", "")
],
}
response = requests.post(
f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients",
headers=headers_kc,
json=keycloak_client,
)
if response.status_code == 201:
print(f"Created SAML client: {keycloak_client['clientId']}")
else:
print(
f"Failed: {keycloak_client['clientId']} "
f"- {response.text}"
)
Use our SAML Decoder to validate SAML assertions during migration testing. For SAML configuration guidance, see configuring Keycloak as a SAML SP.
Phase 4: User Migration
User migration is the most sensitive phase. Passwords cannot be extracted from Okta, so you have three options.
Option 1: Password Reset on First Login
Export users from Okta, import into Keycloak without passwords, and require a password reset:
# migrate_users.py
import json
import requests
import time
OKTA_DOMAIN = "your-org.okta.com"
OKTA_TOKEN = "your-okta-token"
KEYCLOAK_URL = "http://localhost:8080"
REALM = "production"
KC_TOKEN = "your-admin-token"
headers_okta = {
"Authorization": f"SSWS {OKTA_TOKEN}",
"Accept": "application/json",
}
headers_kc = {
"Authorization": f"Bearer {KC_TOKEN}",
"Content-Type": "application/json",
}
def fetch_all_okta_users():
"""Fetch all users from Okta with pagination."""
users = []
url = f"https://{OKTA_DOMAIN}/api/v1/users?limit=200"
while url:
response = requests.get(url, headers=headers_okta)
users.extend(response.json())
# Handle pagination via Link header
links = response.headers.get("Link", "")
url = None
for link in links.split(","):
if 'rel="next"' in link:
url = link.split(";")[0].strip().strip("<>")
# Respect rate limits
remaining = int(
response.headers.get("X-Rate-Limit-Remaining", 100)
)
if remaining < 10:
time.sleep(1)
return users
def migrate_user(okta_user):
"""Migrate a single user to Keycloak."""
profile = okta_user.get("profile", {})
keycloak_user = {
"username": profile.get(
"login", profile.get("email", "")
),
"email": profile.get("email", ""),
"firstName": profile.get("firstName", ""),
"lastName": profile.get("lastName", ""),
"enabled": okta_user.get("status") == "ACTIVE",
"emailVerified": True,
"requiredActions": ["UPDATE_PASSWORD"],
"attributes": {
"okta_id": [okta_user["id"]],
"phone": [profile.get("mobilePhone", "")],
"title": [profile.get("title", "")],
"department": [profile.get("department", "")],
"migration_date": ["2026-04-25"],
},
}
response = requests.post(
f"{KEYCLOAK_URL}/admin/realms/{REALM}/users",
headers=headers_kc,
json=keycloak_user,
)
return response.status_code, keycloak_user["username"]
# Run migration
print("Fetching users from Okta...")
okta_users = fetch_all_okta_users()
print(f"Found {len(okta_users)} users")
results = {"created": 0, "skipped": 0, "failed": 0}
for user in okta_users:
status, username = migrate_user(user)
if status == 201:
results["created"] += 1
elif status == 409:
results["skipped"] += 1
else:
results["failed"] += 1
print(f"Failed: {username} (status {status})")
# Progress update every 100 users
total = sum(results.values())
if total % 100 == 0:
print(f"Progress: {total}/{len(okta_users)}")
print(f"nMigration complete: {results}")
Option 2: Okta as Identity Provider (Zero-Downtime)
Keep Okta running and configure it as an identity provider in Keycloak. Users authenticate through Okta initially, and Keycloak creates local accounts on first login:
- In Keycloak, go to Identity Providers > Add Provider > OpenID Connect v1.0
- Set the discovery URL to
https://your-org.okta.com/.well-known/openid-configuration - Enter the Okta application’s client ID and secret
- Configure First Login Flow to create users and map attributes
This approach lets you migrate applications to Keycloak one by one while Okta remains the user store. Once all applications point to Keycloak, you can migrate passwords (by having users set new passwords) and remove the Okta IdP.
For detailed identity brokering setup, see attribute mapping during OIDC brokering.
Option 3: Parallel Run with Hash Migration
If you have Okta’s password hash export (available for some enterprise plans), you can import hashed passwords directly into Keycloak using the credential import API. This is the smoothest user experience but requires Okta enterprise support cooperation.
Phase 5: MFA Migration
MFA enrollment cannot be directly transferred from Okta to Keycloak. Users will need to re-enroll their MFA devices. Plan for this:
Strategy 1: Grace Period
Allow users to log in without MFA for a defined grace period after migration, during which they must enroll:
- In Keycloak, configure a custom authentication flow
- Add OTP as a conditional step initially
- After the grace period, make OTP required
- Send email notifications prompting users to enroll
Strategy 2: Require on First Login
Set UPDATE_PASSWORD and CONFIGURE_TOTP as required actions for all migrated users:
# After creating the user, set required actions
user_id = "keycloak-user-uuid"
requests.put(
f"{KEYCLOAK_URL}/admin/realms/{REALM}/users/{user_id}",
headers=headers_kc,
json={
"requiredActions": [
"UPDATE_PASSWORD",
"CONFIGURE_TOTP",
]
},
)
For passkey/WebAuthn migration, see enabling passkeys for 2FA in Keycloak and passwordless authentication with passkeys.
For a broader look at MFA integration patterns, see multi-factor authentication integration patterns and Keycloak’s MFA features.
Phase 6: Application Cutover
Update OIDC Applications
For each application currently pointing to Okta:
- Update the OIDC discovery URL from
https://your-org.okta.com/.well-known/openid-configurationtohttps://keycloak.example.com/realms/production/.well-known/openid-configuration - Update the client ID and client secret
- Update redirect URIs if the Keycloak domain differs from Okta
- Test the login flow end-to-end
Update SAML Applications
For SAML applications:
- Download Keycloak’s IdP metadata from
https://keycloak.example.com/realms/production/protocol/saml/descriptor - Upload it to the service provider
- Update the ACS URL and entity ID in Keycloak’s SAML client configuration
- Test SSO and SLO flows
Use our SAML Decoder to compare Okta and Keycloak SAML assertions during testing.
Update SCIM Provisioning
If you use SCIM for user provisioning, reconfigure your downstream applications to use Keycloak’s SCIM endpoint. See using SCIM 2.0 with managed Keycloak and our SCIM feature overview. Test provisioning flows with our SCIM Endpoint Tester.
Phase 7: Phased Rollout
Do not migrate everything at once. Use a phased approach:
Week 1–2: Internal Applications
- Migrate internal tools and admin panels first
- These have smaller user bases and more tolerance for issues
- Validate login flows, token claims, and role mappings
Week 3–4: Non-Critical External Applications
- Migrate customer-facing applications that are not business-critical
- Monitor authentication metrics and error rates
- Collect user feedback on the login experience
Week 5–6: Critical Applications
- Migrate primary customer-facing applications
- Have rollback procedures ready (DNS or load balancer switch back to Okta)
- Monitor closely for 48 hours after each cutover
Week 7+: Cleanup
- Remove Okta as an identity provider (if using Option 2)
- Decommission Okta integration endpoints
- Archive Okta export data securely
- Update documentation and runbooks
Monitoring the Migration
Track these metrics throughout the migration:
- Authentication success rate — should remain above 99.5%
- Login latency — compare Okta vs Keycloak p50/p95/p99
- MFA enrollment rate — track how many users have re-enrolled
- Support ticket volume — spikes indicate user-facing issues
- Error patterns — monitor Keycloak audit logs for failed logins
Skycloak’s Insights dashboard provides real-time visibility into these metrics without needing to set up custom monitoring.
Rollback Plan
Always maintain the ability to roll back:
- Keep Okta active during the migration window (even if you are not routing traffic to it)
- Use DNS or load balancer switching to redirect auth traffic between Okta and Keycloak
- Do not delete Okta users or applications until the migration is fully validated
- Document the rollback procedure and test it before starting the production cutover
Cost Considerations
One of the primary reasons for migrating from Okta to Keycloak is cost. Okta’s per-user pricing scales linearly, while Keycloak (self-hosted or managed) does not charge per user.
Use our ROI Calculator to estimate savings for your specific user count. For a broader cost analysis, see self-hosting vs managed auth: the true cost comparison and the detailed Keycloak vs Okta comparison.
Further Reading
- Keycloak Server Administration Guide
- Okta API Documentation
- Migrating from Auth0 to Keycloak — similar migration patterns
- Keycloak realm export and import — for configuration portability
- Identity brokering with FusionAuth — bridging between identity providers
Wrapping Up
Migrating from Okta to Keycloak is a significant project, but it follows a well-defined pattern: audit, map, configure, migrate users, update applications, and cut over. The phased approach described here minimizes risk and gives you escape hatches at every stage.
If you want the flexibility and cost advantages of Keycloak without the burden of operating it yourself, Skycloak provides fully managed Keycloak with built-in security, guaranteed SLA, and a team that handles upgrades, scaling, and monitoring. Check our pricing page to see how it compares to your current Okta spend.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.