How to Migrate from Okta to Keycloak

Guilliano Molaire Guilliano Molaire Updated May 8, 2026 11 min read

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:

  1. Audit — inventory everything in Okta (users, apps, policies, integrations)
  2. Map — translate Okta concepts to Keycloak equivalents
  3. Configure — set up Keycloak with matching clients, roles, and flows
  4. Migrate users — export from Okta, import into Keycloak
  5. Migrate applications — update each application’s OIDC/SAML configuration
  6. Test — validate every authentication flow
  7. 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:

  1. In Keycloak, go to Identity Providers > Add Provider > OpenID Connect v1.0
  2. Set the discovery URL to https://your-org.okta.com/.well-known/openid-configuration
  3. Enter the Okta application’s client ID and secret
  4. 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:

  1. In Keycloak, configure a custom authentication flow
  2. Add OTP as a conditional step initially
  3. After the grace period, make OTP required
  4. 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:

  1. Update the OIDC discovery URL from https://your-org.okta.com/.well-known/openid-configuration to https://keycloak.example.com/realms/production/.well-known/openid-configuration
  2. Update the client ID and client secret
  3. Update redirect URIs if the Keycloak domain differs from Okta
  4. Test the login flow end-to-end

Update SAML Applications

For SAML applications:

  1. Download Keycloak’s IdP metadata from https://keycloak.example.com/realms/production/protocol/saml/descriptor
  2. Upload it to the service provider
  3. Update the ACS URL and entity ID in Keycloak’s SAML client configuration
  4. 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:

  1. Keep Okta active during the migration window (even if you are not routing traffic to it)
  2. Use DNS or load balancer switching to redirect auth traffic between Okta and Keycloak
  3. Do not delete Okta users or applications until the migration is fully validated
  4. 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

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.

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman