AIgent

Migrating from Keycloak to FusionAuth: A Comprehensive Guide

Migration Complexity: Medium to High
Estimated Timeline: 2-8 weeks depending on customizations
Recommended Team: 2-3 developers + 1 DevOps engineer

Executive Summary

This guide provides a structured approach to migrating from Keycloak to FusionAuth, addressing common challenges and providing practical solutions for enterprise environments. FusionAuth offers superior developer experience, simplified deployment, and cost-effective scaling compared to Keycloak’s complex multi-service architecture.

Why Migrate to FusionAuth?

Key Advantages Over Keycloak

Migration Triggers

Pre-Migration Assessment

Current State Inventory

# Keycloak Data Export Script
#!/bin/bash

KEYCLOAK_HOME="/opt/keycloak"
BACKUP_DIR="/tmp/keycloak-export-$(date +%Y%m%d)"

# Export all realms
$KEYCLOAK_HOME/bin/standalone.sh \
  -Djboss.socket.binding.port-offset=100 \
  -Dkeycloak.migration.action=export \
  -Dkeycloak.migration.provider=dir \
  -Dkeycloak.migration.dir=$BACKUP_DIR \
  -Dkeycloak.migration.usersExportStrategy=REALM_FILE

echo "Export completed in: $BACKUP_DIR"

Assessment Checklist

Migration Strategy

Phase 1: Environment Preparation (Week 1)

1.1 FusionAuth Installation

# docker-compose.yml for FusionAuth
version: '3.8'
services:
  fusionauth:
    image: fusionauth/fusionauth-app:1.50.1
    depends_on:
      - db
    environment:
      DATABASE_URL: jdbc:postgresql://db:5432/fusionauth
      DATABASE_USERNAME: fusionauth
      DATABASE_PASSWORD: ${DB_PASSWORD}
      FUSIONAUTH_APP_MEMORY: 512M
      FUSIONAUTH_APP_RUNTIME_MODE: production
      SEARCH_ENGINE_TYPE: elasticsearch
      SEARCH_SERVERS: http://search:9200
    ports:
      - "9011:9011"
    volumes:
      - fusionauth_config:/usr/local/fusionauth/config

  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: fusionauth
      POSTGRES_USER: fusionauth
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - db_data:/var/lib/postgresql/data

  search:
    image: opensearchproject/opensearch:2.11.0
    environment:
      - discovery.type=single-node
      - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m"
      - "DISABLE_SECURITY_PLUGIN=true"
    volumes:
      - search_data:/usr/share/opensearch/data

volumes:
  fusionauth_config:
  db_data:
  search_data:

1.2 API Client Setup

// migration-client.js
const FusionAuthClient = require('@fusionauth/typescript-client');

class MigrationClient {
  constructor(apiKey, baseUrl = 'http://localhost:9011') {
    this.client = new FusionAuthClient(apiKey, baseUrl);
    this.batchSize = 100;
  }

  async createApplication(keycloakClient) {
    const application = {
      name: keycloakClient.clientId,
      roles: keycloakClient.defaultRoles?.map(role => ({ name: role })) || [],
      oauthConfiguration: {
        clientId: keycloakClient.clientId,
        clientSecret: keycloakClient.secret,
        enabledGrants: this.mapGrantTypes(keycloakClient.standardFlowEnabled),
        redirectURIs: keycloakClient.redirectUris || [],
        logoutURL: keycloakClient.attributes?.['post.logout.redirect.uris']
      }
    };

    return await this.client.createApplication(null, { application });
  }

  mapGrantTypes(standardFlow) {
    const grants = [];
    if (standardFlow) grants.push('authorization_code');
    // Add other grant type mappings
    return grants;
  }
}

Phase 2: Data Migration (Week 2-3)

2.1 User Migration Script

#!/usr/bin/env python3
"""
FusionAuth User Migration Script
Migrates users from Keycloak export to FusionAuth
"""

import json
import requests
import hashlib
from datetime import datetime
from typing import Dict, List, Optional

class UserMigrator:
    def __init__(self, fusionauth_url: str, api_key: str):
        self.base_url = fusionauth_url
        self.headers = {
            'Authorization': api_key,
            'Content-Type': 'application/json'
        }
        
    def migrate_users(self, keycloak_export_file: str, application_id: str):
        """Migrate users from Keycloak export"""
        with open(keycloak_export_file, 'r') as f:
            keycloak_data = json.load(f)
        
        users = keycloak_data.get('users', [])
        migrated = 0
        
        for user_batch in self._batch(users, 50):
            try:
                self._migrate_user_batch(user_batch, application_id)
                migrated += len(user_batch)
                print(f"Migrated {migrated}/{len(users)} users")
            except Exception as e:
                print(f"Batch failed: {e}")
                continue
    
    def _migrate_user_batch(self, users: List[Dict], application_id: str):
        """Migrate a batch of users"""
        fusion_users = []
        
        for kc_user in users:
            fusion_user = self._transform_user(kc_user, application_id)
            fusion_users.append(fusion_user)
        
        # Import users with existing passwords
        response = requests.post(
            f"{self.base_url}/api/user/import",
            headers=self.headers,
            json={
                'users': fusion_users,
                'validateDbConstraints': True
            }
        )
        
        if response.status_code != 200:
            raise Exception(f"Import failed: {response.text}")
    
    def _transform_user(self, kc_user: Dict, application_id: str) -> Dict:
        """Transform Keycloak user to FusionAuth format"""
        
        # Map user attributes
        user_data = {
            'email': kc_user.get('email'),
            'username': kc_user.get('username'),
            'firstName': kc_user.get('firstName'),
            'lastName': kc_user.get('lastName'),
            'active': kc_user.get('enabled', True),
            'verified': kc_user.get('emailVerified', False),
            'data': {
                'migratedFrom': 'keycloak',
                'migrationDate': datetime.utcnow().isoformat()
            }
        }
        
        # Handle password migration
        if kc_user.get('credentials'):
            for cred in kc_user['credentials']:
                if cred['type'] == 'password':
                    user_data['encryptionScheme'] = 'bcrypt'
                    user_data['password'] = cred['hashedSaltedValue']
                    user_data['salt'] = cred['salt']
                    break
        
        # Map custom attributes
        if kc_user.get('attributes'):
            for key, values in kc_user['attributes'].items():
                if key.startswith('custom_'):
                    user_data['data'][key] = values[0] if len(values) == 1 else values
        
        # Map roles to application registrations
        registrations = []
        if application_id:
            roles = []
            if kc_user.get('realmRoles'):
                roles.extend(kc_user['realmRoles'])
            
            registrations.append({
                'applicationId': application_id,
                'roles': roles,
                'data': {}
            })
        
        return {
            'user': user_data,
            'registrations': registrations
        }
    
    def _batch(self, items: List, size: int):
        """Yield successive batches from items"""
        for i in range(0, len(items), size):
            yield items[i:i + size]

if __name__ == "__main__":
    migrator = UserMigrator(
        fusionauth_url="http://localhost:9011",
        api_key="YOUR_API_KEY"
    )
    
    migrator.migrate_users(
        keycloak_export_file="realm-export.json",
        application_id="YOUR_APPLICATION_ID"
    )

2.2 Application Migration

// application-migrator.js
class ApplicationMigrator {
  constructor(client) {
    this.client = client;
  }

  async migrateApplications(keycloakClients) {
    const results = [];
    
    for (const kcClient of keycloakClients) {
      try {
        const fusionAuthApp = await this.transformApplication(kcClient);
        const response = await this.client.createApplication(null, {
          application: fusionAuthApp
        });
        
        results.push({
          keycloakId: kcClient.id,
          fusionAuthId: response.successResponse.application.id,
          status: 'migrated'
        });
      } catch (error) {
        results.push({
          keycloakId: kcClient.id,
          error: error.message,
          status: 'failed'
        });
      }
    }
    
    return results;
  }

  async transformApplication(kcClient) {
    return {
      name: kcClient.name || kcClient.clientId,
      roles: this.mapRoles(kcClient.defaultRoles),
      oauthConfiguration: {
        clientId: kcClient.clientId,
        clientSecret: kcClient.secret,
        enabledGrants: this.mapGrants(kcClient),
        redirectURIs: kcClient.redirectUris || [],
        logoutURL: this.extractLogoutUrl(kcClient),
        requireClientAuthentication: !kcClient.publicClient
      },
      samlv2Configuration: kcClient.protocol === 'saml' ? {
        issuer: kcClient.clientId,
        audience: kcClient.attributes?.['saml_assertion_consumer_url_post'],
        callbackURL: kcClient.attributes?.['saml_assertion_consumer_url_post']
      } : undefined,
      data: {
        migratedFrom: 'keycloak',
        originalId: kcClient.id
      }
    };
  }

  mapGrants(kcClient) {
    const grants = [];
    if (kcClient.standardFlowEnabled) grants.push('authorization_code');
    if (kcClient.implicitFlowEnabled) grants.push('implicit');
    if (kcClient.serviceAccountsEnabled) grants.push('client_credentials');
    if (kcClient.directAccessGrantsEnabled) grants.push('password');
    return grants;
  }

  mapRoles(defaultRoles = []) {
    return defaultRoles.map(role => ({ name: role, isDefault: true }));
  }
}

Phase 3: Identity Provider Migration (Week 3)

3.1 SAML Identity Provider

// saml-idp-migrator.js
async function migrateSamlProvider(keycloakIdp) {
  const fusionAuthIdp = {
    name: keycloakIdp.alias,
    type: 'SAMLv2',
    enabled: keycloakIdp.enabled,
    samlv2: {
      issuer: keycloakIdp.config.singleSignOnServiceUrl,
      keystore: {
        // Convert Keycloak certificate to FusionAuth format
        certificate: await convertCertificate(keycloakIdp.config.signingCertificate)
      },
      useNameIdForEmail: keycloakIdp.config.wantAssertionsSigned === 'true',
      xmlSignatureC14nMethod: keycloakIdp.config.xmlSigKeyInfoKeyNameTransformer,
      requestSigningKeyId: 'default-signing-key'
    },
    linkingStrategy: keycloakIdp.linkOnly ? 'LinkByEmail' : 'CreatePendingLink'
  };

  return await fusionAuthClient.createIdentityProvider(null, {
    identityProvider: fusionAuthIdp
  });
}

Phase 4: Testing & Validation (Week 4)

4.1 Migration Validation Script

#!/bin/bash
# migration-validator.sh

echo "Starting FusionAuth migration validation..."

# Test user authentication
echo "Testing user authentication..."
curl -X POST "http://localhost:9011/api/login" \
  -H "Authorization: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "loginId": "test@example.com",
    "password": "password123",
    "applicationId": "'$APP_ID'"
  }' | jq '.token' > /tmp/token.txt

if [ -s /tmp/token.txt ]; then
    echo "✅ User authentication successful"
else
    echo "❌ User authentication failed"
    exit 1
fi

# Test OAuth flow
echo "Testing OAuth authorization code flow..."
AUTH_URL="http://localhost:9011/oauth2/authorize?client_id=$CLIENT_ID&response_type=code&redirect_uri=http://localhost:3000/callback"
echo "Manual test required: $AUTH_URL"

# Test SAML if configured
if [ ! -z "$SAML_IDP_ID" ]; then
    echo "Testing SAML authentication..."
    SAML_URL="http://localhost:9011/samlv2/idp/initiate?identityProviderId=$SAML_IDP_ID"
    echo "Manual test required: $SAML_URL"
fi

echo "Validation complete. Manual testing URLs generated."

Phase 5: Cutover Planning (Week 4-5)

5.1 DNS/Load Balancer Switch

# nginx-cutover.conf
upstream keycloak_backend {
    server keycloak1.internal:8080;
    server keycloak2.internal:8080;
}

upstream fusionauth_backend {
    server fusionauth1.internal:9011;
    server fusionauth2.internal:9011;
}

server {
    listen 443 ssl;
    server_name auth.company.com;
    
    # Blue-Green deployment switch
    set $backend fusionauth_backend;  # Change this for cutover
    
    location /auth/ {
        # Keycloak paths
        proxy_pass http://$backend;
    }
    
    location /oauth2/ {
        # FusionAuth paths  
        proxy_pass http://$backend;
    }
}

5.2 Application Configuration Updates

// Updated application config for FusionAuth
const authConfig = {
  // Before (Keycloak)
  // authority: 'https://auth.company.com/auth/realms/company',
  
  // After (FusionAuth)
  authority: 'https://auth.company.com',
  clientId: 'same-client-id',
  clientSecret: 'same-client-secret',
  redirectUri: 'https://app.company.com/callback',
  
  // Updated endpoints
  endpoints: {
    authorization: '/oauth2/authorize',
    token: '/oauth2/token',
    userInfo: '/oauth2/userinfo',
    jwks: '/.well-known/jwks_uri'
  }
};

Common Challenges & Solutions

Challenge 1: Custom Keycloak Extensions

Problem: Custom authenticators, event listeners, or SPIs Solution:

// FusionAuth Lambda for custom claims
function populate(jwt, user, registration) {
  // Custom claim logic previously in Keycloak SPI
  jwt.department = user.data.department;
  jwt.permissions = registration.data.permissions || [];
}

Challenge 2: Theme Migration

Problem: Custom Keycloak themes Solution: Convert to FusionAuth themes using Freemarker templates

<!-- FusionAuth login template -->
<#import "/_helpers.ftl" as helpers>
<#assign customCSS = "/* Your custom CSS */"/>

<@helpers.html>
  <@helpers.head>
    <style>${customCSS}</style>
  </@helpers.head>
  <@helpers.body>
    <div class="custom-login-form">
      <!-- Your custom login form -->
    </div>
  </@helpers.body>
</@helpers.html>

Challenge 3: Session Management

Problem: Different session handling between systems Solution:

Post-Migration Optimization

Performance Tuning

# fusionauth.properties optimization
fusionauth-app.memory=2g
fusionauth-app.additional-java-args=-XX:+UseG1GC -XX:MaxGCPauseMillis=100

# Database optimization
database.mysql.maximum-pool-size=50
database.mysql.minimum-idle=10

# Search optimization  
fusionauth-search.memory=1g

Monitoring Setup

# Prometheus metrics
version: '3.8'
services:
  fusionauth:
    image: fusionauth/fusionauth-app:1.50.1
    environment:
      FUSIONAUTH_APP_ADDITIONAL_JAVA_ARGS: >
        -javaagent:/usr/local/fusionauth/jmx_prometheus_javaagent.jar=8081:/usr/local/fusionauth/prometheus-config.yml
    volumes:
      - ./monitoring/prometheus-config.yml:/usr/local/fusionauth/prometheus-config.yml
    ports:
      - "8081:8081"  # Metrics endpoint

Rollback Plan

Emergency Rollback Procedure

  1. DNS Switch: Revert DNS/load balancer to Keycloak
  2. Database Restore: Have Keycloak database backup ready
  3. Application Config: Keep Keycloak configs in version control
  4. Communication: Notify stakeholders immediately
#!/bin/bash
# emergency-rollback.sh
echo "EMERGENCY ROLLBACK INITIATED"

# Switch load balancer
kubectl patch configmap nginx-config --patch '{"data":{"backend":"keycloak_backend"}}'

# Restart applications with Keycloak config
kubectl rollout restart deployment/app-frontend
kubectl rollout restart deployment/app-backend

echo "Rollback complete. Monitor applications."

Success Metrics

Conclusion

This migration approach prioritizes data integrity, minimal downtime, and thorough testing. The modular approach allows for rollback at each phase, ensuring business continuity throughout the process.

Key success factors:

For enterprise environments, consider engaging FusionAuth professional services for complex migrations involving extensive customizations or integrations.