How to Migrate from AWS Cognito to Keycloak

Guilliano Molaire Guilliano Molaire Updated May 15, 2026 12 min read

Last updated: March 2026

AWS Cognito is a convenient choice when your entire stack is on AWS, but teams outgrow it. The limitations become apparent as requirements scale: restricted customization of authentication flows, limited federation options, complex pricing at scale, and vendor lock-in to the AWS ecosystem. Migrating to Keycloak gives you full control over your identity infrastructure, true open-source flexibility, and the ability to run anywhere.

This guide provides a step-by-step migration plan from Cognito User Pools to Keycloak, covering user export, password handling, application client migration, custom attribute mapping, and converting Lambda triggers to Keycloak flows.

Migration Planning

Before writing any code, assess the scope of the migration.

What Can Be Migrated

Component Migrateable Notes
User profiles Yes Email, phone, name, custom attributes
User groups Yes Map to Keycloak groups or roles
App clients Yes Recreate as Keycloak clients
Custom attributes Yes Map to Keycloak user attributes
MFA configuration Partially TOTP secrets cannot be exported from Cognito
Passwords No Cognito does not export password hashes
Lambda triggers Yes (rewrite) Convert to Keycloak authentication flows/SPIs
Hosted UI customization Yes (rewrite) Recreate as Keycloak themes

The Password Problem

The biggest challenge in any Cognito migration is passwords. Cognito does not allow you to export password hashes. This means you cannot migrate passwords directly. There are two strategies:

  1. Force password reset: After migration, all users must set a new password. Simple but disruptive.
  2. Lazy migration (recommended): Use Keycloak’s User Federation SPI to authenticate against Cognito during a transition period. When a user logs in, Keycloak validates their credentials against Cognito, then stores them locally. Over time, all active users migrate transparently.

We will cover both approaches.

Step 1: Export Users from Cognito

Use the AWS CLI or SDK to export users from Cognito. The ListUsers API paginates through all users in the pool.

Using AWS CLI

#!/bin/bash
# export-cognito-users.sh

USER_POOL_ID="us-east-1_XXXXXXXXX"
OUTPUT_FILE="cognito-users.json"

echo "[" > "$OUTPUT_FILE"
FIRST=true
PAGINATION_TOKEN=""

while true; do
  if [ -z "$PAGINATION_TOKEN" ]; then
    RESPONSE=$(aws cognito-idp list-users 
      --user-pool-id "$USER_POOL_ID" 
      --limit 60 
      --output json)
  else
    RESPONSE=$(aws cognito-idp list-users 
      --user-pool-id "$USER_POOL_ID" 
      --limit 60 
      --pagination-token "$PAGINATION_TOKEN" 
      --output json)
  fi

  USERS=$(echo "$RESPONSE" | jq '.Users')
  COUNT=$(echo "$USERS" | jq 'length')

  for i in $(seq 0 $((COUNT - 1))); do
    if [ "$FIRST" = true ]; then
      FIRST=false
    else
      echo "," >> "$OUTPUT_FILE"
    fi
    echo "$USERS" | jq ".[$i]" >> "$OUTPUT_FILE"
  done

  PAGINATION_TOKEN=$(echo "$RESPONSE" | jq -r '.PaginationToken // empty')

  if [ -z "$PAGINATION_TOKEN" ]; then
    break
  fi

  echo "Exported $COUNT users, continuing..."
done

echo "]" >> "$OUTPUT_FILE"
echo "Export complete. Users saved to $OUTPUT_FILE"

Using Python

For larger user pools, the Python SDK handles pagination more cleanly and can include group memberships:

#!/usr/bin/env python3
"""Export all users and groups from a Cognito User Pool."""

import json
import boto3

USER_POOL_ID = "us-east-1_XXXXXXXXX"
OUTPUT_FILE = "cognito-users.json"

client = boto3.client("cognito-idp")

def get_all_users():
    """Paginate through all users in the pool."""
    users = []
    params = {"UserPoolId": USER_POOL_ID, "Limit": 60}

    while True:
        response = client.list_users(**params)
        users.extend(response["Users"])
        print(f"  Fetched {len(users)} users so far...")

        if "PaginationToken" in response:
            params["PaginationToken"] = response["PaginationToken"]
        else:
            break

    return users

def get_user_groups(username):
    """Get groups for a specific user."""
    groups = []
    params = {"UserPoolId": USER_POOL_ID, "Username": username, "Limit": 60}

    while True:
        response = client.admin_list_groups_for_user(**params)
        groups.extend([g["GroupName"] for g in response["Groups"]])

        if "NextToken" in response:
            params["NextToken"] = response["NextToken"]
        else:
            break

    return groups

def extract_attribute(attributes, name):
    """Extract a specific attribute from Cognito's attribute list."""
    for attr in attributes:
        if attr["Name"] == name:
            return attr["Value"]
    return None

def transform_user(cognito_user):
    """Transform a Cognito user to a migration-friendly format."""
    attrs = cognito_user.get("Attributes", [])

    return {
        "username": cognito_user["Username"],
        "email": extract_attribute(attrs, "email"),
        "email_verified": extract_attribute(attrs, "email_verified") == "true",
        "phone_number": extract_attribute(attrs, "phone_number"),
        "phone_verified": extract_attribute(attrs, "phone_number_verified") == "true",
        "given_name": extract_attribute(attrs, "given_name"),
        "family_name": extract_attribute(attrs, "family_name"),
        "name": extract_attribute(attrs, "name"),
        "status": cognito_user["UserStatus"],
        "enabled": cognito_user["Enabled"],
        "created": cognito_user["UserCreateDate"].isoformat(),
        "modified": cognito_user["UserLastModifiedDate"].isoformat(),
        "custom_attributes": {
            attr["Name"]: attr["Value"]
            for attr in attrs
            if attr["Name"].startswith("custom:")
        },
        "groups": get_user_groups(cognito_user["Username"]),
    }

def main():
    print("Exporting users from Cognito...")
    cognito_users = get_all_users()

    print(f"Transforming {len(cognito_users)} users...")
    transformed = [transform_user(u) for u in cognito_users]

    with open(OUTPUT_FILE, "w") as f:
        json.dump(transformed, f, indent=2, default=str)

    print(f"Export complete. {len(transformed)} users saved to {OUTPUT_FILE}")

if __name__ == "__main__":
    main()

Step 2: Import Users into Keycloak

Use Keycloak’s Admin REST API to create users. This script reads the exported JSON and creates users in Keycloak with the REQUIRED_ACTIONS set to force a password reset.

#!/usr/bin/env python3
"""Import Cognito users into Keycloak via the Admin REST API."""

import json
import sys

import requests

KEYCLOAK_URL = "https://keycloak.example.com"
REALM = "my-realm"
ADMIN_USER = "admin"
ADMIN_PASSWORD = "admin"

USERS_FILE = "cognito-users.json"

def get_admin_token():
    """Get an admin access token."""
    response = requests.post(
        f"{KEYCLOAK_URL}/realms/master/protocol/openid-connect/token",
        data={
            "grant_type": "password",
            "client_id": "admin-cli",
            "username": ADMIN_USER,
            "password": ADMIN_PASSWORD,
        },
    )
    response.raise_for_status()
    return response.json()["access_token"]

def create_group_if_not_exists(token, group_name):
    """Create a Keycloak group if it does not exist. Returns the group ID."""
    headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}

    # Check if group exists
    response = requests.get(
        f"{KEYCLOAK_URL}/admin/realms/{REALM}/groups",
        headers=headers,
        params={"search": group_name, "exact": "true"},
    )

    groups = response.json()
    for group in groups:
        if group["name"] == group_name:
            return group["id"]

    # Create the group
    response = requests.post(
        f"{KEYCLOAK_URL}/admin/realms/{REALM}/groups",
        headers=headers,
        json={"name": group_name},
    )

    if response.status_code == 201:
        location = response.headers.get("Location", "")
        return location.split("/")[-1]

    return None

def import_user(token, user_data):
    """Import a single user into Keycloak."""
    headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}

    # Build the Keycloak user representation
    keycloak_user = {
        "username": user_data.get("email") or user_data["username"],
        "email": user_data.get("email"),
        "emailVerified": user_data.get("email_verified", False),
        "firstName": user_data.get("given_name", ""),
        "lastName": user_data.get("family_name", ""),
        "enabled": user_data.get("enabled", True),
        "requiredActions": ["UPDATE_PASSWORD"],
        "attributes": {},
    }

    # Map custom attributes
    custom_attrs = user_data.get("custom_attributes", {})
    for key, value in custom_attrs.items():
        # Remove the "custom:" prefix from Cognito attribute names
        attr_name = key.replace("custom:", "")
        keycloak_user["attributes"][attr_name] = [value]

    # Preserve Cognito metadata
    keycloak_user["attributes"]["cognito_username"] = [user_data["username"]]
    keycloak_user["attributes"]["cognito_status"] = [user_data.get("status", "")]
    keycloak_user["attributes"]["migration_source"] = ["cognito"]

    if user_data.get("phone_number"):
        keycloak_user["attributes"]["phoneNumber"] = [user_data["phone_number"]]
        keycloak_user["attributes"]["phoneNumberVerified"] = [
            str(user_data.get("phone_verified", False)).lower()
        ]

    # Create the user
    response = requests.post(
        f"{KEYCLOAK_URL}/admin/realms/{REALM}/users",
        headers=headers,
        json=keycloak_user,
    )

    if response.status_code == 201:
        user_id = response.headers["Location"].split("/")[-1]

        # Add user to groups
        for group_name in user_data.get("groups", []):
            group_id = create_group_if_not_exists(token, group_name)
            if group_id:
                requests.put(
                    f"{KEYCLOAK_URL}/admin/realms/{REALM}/users/{user_id}/groups/{group_id}",
                    headers=headers,
                )

        return "created", user_data.get("email") or user_data["username"]
    elif response.status_code == 409:
        return "exists", user_data.get("email") or user_data["username"]
    else:
        return "error", f"{user_data.get('email', user_data['username'])}: {response.text}"

def main():
    print("Loading users from export file...")
    with open(USERS_FILE) as f:
        users = json.load(f)

    print(f"Found {len(users)} users to import.")

    token = get_admin_token()

    created = 0
    skipped = 0
    errors = 0

    for i, user in enumerate(users):
        status, detail = import_user(token, user)
        if status == "created":
            created += 1
        elif status == "exists":
            skipped += 1
        else:
            errors += 1
            print(f"  ERROR: {detail}")

        if (i + 1) % 100 == 0:
            print(f"  Progress: {i + 1}/{len(users)}")
            # Refresh the admin token periodically
            token = get_admin_token()

    print(f"nImport complete:")
    print(f"  Created: {created}")
    print(f"  Skipped (existing): {skipped}")
    print(f"  Errors: {errors}")

if __name__ == "__main__":
    main()

Step 3: Handle Passwords with Lazy Migration

The recommended approach is lazy migration: during a transition period, Keycloak authenticates users against Cognito, then stores the password locally. This avoids forcing all users to reset their passwords.

Keycloak User Federation for Cognito

Keycloak’s User Storage SPI lets you build a custom provider that validates credentials against Cognito. Here is the high-level implementation:

public class CognitoUserStorageProvider implements UserStorageProvider,
        CredentialInputValidator, UserLookupProvider {

    private final KeycloakSession session;
    private final ComponentModel model;
    private final AWSCognitoIdentityProvider cognitoClient;

    public CognitoUserStorageProvider(KeycloakSession session, ComponentModel model) {
        this.session = session;
        this.model = model;

        String userPoolId = model.getConfig().getFirst("userPoolId");
        String clientId = model.getConfig().getFirst("clientId");
        String region = model.getConfig().getFirst("region");

        this.cognitoClient = AWSCognitoIdentityProviderClientBuilder.standard()
                .withRegion(region)
                .build();
    }

    @Override
    public boolean isValid(RealmModel realm, UserModel user,
                           CredentialInput credentialInput) {
        if (!supportsCredentialType(credentialInput.getType())) {
            return false;
        }

        try {
            // Attempt to authenticate against Cognito
            AdminInitiateAuthRequest authRequest = new AdminInitiateAuthRequest()
                    .withUserPoolId(model.getConfig().getFirst("userPoolId"))
                    .withClientId(model.getConfig().getFirst("clientId"))
                    .withAuthFlow(AuthFlowType.ADMIN_NO_SRP_AUTH)
                    .withAuthParameters(Map.of(
                            "USERNAME", user.getUsername(),
                            "PASSWORD", credentialInput.getChallengeResponse()
                    ));

            cognitoClient.adminInitiateAuth(authRequest);

            // Authentication succeeded - store the password locally in Keycloak
            session.userCredentialManager().updateCredential(
                    realm, user,
                    UserCredentialModel.password(credentialInput.getChallengeResponse())
            );

            // Remove the user from the Cognito federation (they are now local)
            // This is optional - you may want to keep the link during transition
            return true;

        } catch (NotAuthorizedException e) {
            return false;
        } catch (UserNotFoundException e) {
            return false;
        }
    }

    @Override
    public boolean supportsCredentialType(String credentialType) {
        return CredentialModel.PASSWORD.equals(credentialType);
    }
}

Deploy this as a JAR in Keycloak’s providers directory, then configure it:

  1. Go to User Federation in the Admin Console.
  2. Add your Cognito provider.
  3. Configure the User Pool ID, Client ID, and AWS region.

When a user logs in, Keycloak first checks its local database. If the user exists but has no local password (imported without one), Keycloak falls through to the Cognito federation provider. If Cognito validates the password, Keycloak stores it locally and future logins are handled entirely by Keycloak.

Alternative: Force Password Reset

If lazy migration is too complex, you can force password resets. The import script above already sets requiredActions: ["UPDATE_PASSWORD"], which forces users to set a new password on their next login.

To notify users about the migration:

# Send password reset emails via Keycloak Admin API
def send_reset_email(token, user_id):
    response = requests.put(
        f"{KEYCLOAK_URL}/admin/realms/{REALM}/users/{user_id}/execute-actions-email",
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
        },
        json=["UPDATE_PASSWORD"],
        params={"lifespan": 604800},  # Link valid for 7 days
    )
    return response.status_code == 204

Step 4: Migrate App Clients

Map Cognito App Clients to Keycloak Clients:

Cognito App Client Setting Keycloak Client Setting
App client name Client ID
App client secret Client Secret (Credentials tab)
Allowed OAuth Flows Standard Flow, Implicit Flow, Direct Access Grants
Allowed OAuth Scopes Client Scopes
Callback URLs Valid Redirect URIs
Sign out URLs Valid Post Logout Redirect URIs
Token expiration Access Token Lifespan (Advanced Settings)

Example: creating a Keycloak client via the Admin API:

curl -X POST "$KEYCLOAK_URL/admin/realms/$REALM/clients" 
  -H "Authorization: Bearer $ADMIN_TOKEN" 
  -H "Content-Type: application/json" 
  -d '{
    "clientId": "my-web-app",
    "name": "My Web Application",
    "protocol": "openid-connect",
    "publicClient": true,
    "standardFlowEnabled": true,
    "directAccessGrantsEnabled": false,
    "redirectUris": [
      "https://app.example.com/callback",
      "http://localhost:3000/callback"
    ],
    "webOrigins": [
      "https://app.example.com",
      "http://localhost:3000"
    ],
    "attributes": {
      "access.token.lifespan": "3600",
      "pkce.code.challenge.method": "S256"
    }
  }'

For generating Keycloak client configurations, try the Keycloak Config Generator.

Step 5: Convert Lambda Triggers to Keycloak Flows

Cognito uses Lambda triggers for customization. Here is how each trigger maps to Keycloak’s extensibility model:

Cognito Lambda Trigger Keycloak Equivalent
Pre Sign-up Registration Flow (custom authenticator)
Post Confirmation Event Listener SPI
Pre Authentication Authentication Flow (custom authenticator)
Post Authentication Event Listener SPI
Pre Token Generation Protocol Mapper SPI
Custom Message Email Template (FreeMarker)
User Migration User Storage SPI (federation)
Custom Auth Challenge Authenticator SPI

Example: Pre Token Generation Lambda to Protocol Mapper

Cognito Lambda (before):

// Cognito Pre Token Generation Lambda
exports.handler = async (event) => {
  event.response = {
    claimsOverrideDetails: {
      claimsToAddOrOverride: {
        tenant_id: event.request.userAttributes['custom:tenant_id'],
        subscription_plan: event.request.userAttributes['custom:plan'],
      },
    },
  };
  return event;
};

Keycloak Protocol Mapper (after):

  1. Go to Client Scopes > your-scope > Mappers.
  2. Click Add mapper > By configuration.
  3. Select User Attribute.
  4. Configure:
    • Name: tenant_id
    • User Attribute: tenant_id
    • Token Claim Name: tenant_id
    • Claim JSON Type: String
    • Add to access token: ON

Repeat for each custom claim. This is a no-code approach that replaces the Lambda entirely.

For more on custom token claims, see our guide on using custom user attributes in Keycloak OIDC tokens.

Step 6: Migrate Custom Attributes

Cognito custom attributes (prefixed with custom:) map directly to Keycloak user attributes. The import script above handles this mapping:

# From the import script
custom_attrs = user_data.get("custom_attributes", {})
for key, value in custom_attrs.items():
    attr_name = key.replace("custom:", "")
    keycloak_user["attributes"][attr_name] = [value]

To make custom attributes visible in the admin console, create user profile attributes:

  1. Go to Realm Settings > User Profile.
  2. Click Add attribute.
  3. Configure the attribute name, display name, and validation rules.

Step 7: Update Application Code

Before (Cognito SDK)

// AWS Amplify / Cognito SDK
import { Auth } from 'aws-amplify';

await Auth.signIn(username, password);
const session = await Auth.currentSession();
const token = session.getIdToken().getJwtToken();

After (Keycloak)

// keycloak-js
import Keycloak from 'keycloak-js';

const keycloak = new Keycloak({
  url: 'https://keycloak.example.com',
  realm: 'my-realm',
  clientId: 'my-web-app',
});

await keycloak.init({ onLoad: 'login-required' });
const token = keycloak.token;

The backend token validation logic stays similar since both Cognito and Keycloak issue standard JWTs. The main change is updating the JWKS URL and issuer:

// Before (Cognito)
const jwksUrl = `https://cognito-idp.us-east-1.amazonaws.com/${USER_POOL_ID}/.well-known/jwks.json`;
const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${USER_POOL_ID}`;

// After (Keycloak)
const jwksUrl = 'https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs';
const issuer = 'https://keycloak.example.com/realms/my-realm';

You can verify your tokens during migration using the JWT Token Analyzer and test SAML configurations with the SAML Decoder.

Migration Timeline

A realistic migration timeline for a medium-sized application (10,000-50,000 users):

Phase Duration Activities
Planning 1-2 weeks Audit Cognito config, map features, set up Keycloak
Development 2-3 weeks Build import scripts, configure Keycloak, update app code
Testing 1-2 weeks Test user import, authentication flows, edge cases
Parallel running 2-4 weeks Run both systems, lazy migration for passwords
Cutover 1 day Switch DNS/configuration to Keycloak
Decommission 1-2 weeks Verify all users migrated, shut down Cognito

Common Pitfalls

  1. MFA re-enrollment: Cognito does not export TOTP secrets. Users with MFA enabled in Cognito will need to re-enroll their authenticator apps in Keycloak. Communicate this clearly. See Multi-Factor Authentication for Keycloak’s MFA options.

  2. Username vs. email: Cognito allows configuration of whether the username is the email, phone number, or a separate identifier. Ensure your Keycloak realm login settings match.

  3. Custom attribute types: Cognito custom attributes are always strings. If you used Cognito’s Number type, you will need to handle type conversion in Keycloak.

  4. Rate limits: Both the Cognito ListUsers API and the Keycloak Admin API have rate limits. The export and import scripts above handle pagination but may need throttling for very large user pools.

  5. Social login re-linking: If users signed up via social login in Cognito (Google, Facebook), they will need to link their social accounts in Keycloak. Configure the same identity providers in Keycloak and enable automatic account linking.

Cost Comparison

One of the common drivers for migrating away from Cognito is cost. Cognito charges per monthly active user (MAU), with costs increasing significantly at scale and for advanced features (Advanced Security, SAML federation).

Keycloak is free and open-source. Your costs are limited to infrastructure. For a detailed cost analysis, use the ROI Calculator and see our post on what it costs to self-host Keycloak. For managed Keycloak hosting that is typically more cost-effective than Cognito at scale, check Skycloak pricing.

Wrapping Up

Migrating from Cognito to Keycloak is a well-defined process: export users, import them into Keycloak, handle passwords through lazy migration or forced reset, convert Lambda triggers to Keycloak flows, and update your application code. The most challenging aspect is the password migration, which the lazy migration approach handles transparently.

The investment pays off with full control over your identity infrastructure, true open-source flexibility, and freedom from vendor lock-in. Keycloak’s SPI architecture gives you extensibility that Cognito’s Lambda model cannot match.

If you want the power of Keycloak without the operational overhead of managing it yourself, Skycloak provides fully managed Keycloak hosting with migration support, automated backups, and enterprise SLAs — making the transition from Cognito straightforward.

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