Migration Complexity: Medium to High
Estimated Timeline: 2-8 weeks depending on customizations
Recommended Team: 2-3 developers + 1 DevOps engineer
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.
# 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"
# 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:
// 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;
}
}
#!/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"
)
// 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 }));
}
}
// 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
});
}
#!/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."
# 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;
}
}
// 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'
}
};
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 || [];
}
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>
Problem: Different session handling between systems Solution:
# 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
# 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
#!/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."
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.