Skip to main content

Identity Platform Migration

Identity platform migration transfers user accounts, group memberships, authentication configurations, and application integrations from a source identity provider to a target identity provider. Perform this migration when changing identity platforms due to cost optimisation, capability requirements, data sovereignty concerns, or organisational consolidation. A successful migration results in all users authenticating against the new platform with no loss of access to integrated applications.

Prerequisites

RequirementDetail
Target platformIdentity provider deployed, configured, and tested with at least 10 pilot users
Source platform accessGlobal administrator or equivalent role with read access to all users, groups, and audit logs
Target platform accessGlobal administrator or equivalent role with write access to create users, groups, and applications
Migration toolingPlatform-specific migration tool installed and authenticated to both platforms
Application inventoryComplete list of applications integrated with source IdP, including authentication protocol and owner
Rollback windowMinimum 72-hour period where rollback remains possible before source platform decommissioning
Communication planApproved messaging for users covering timeline, expected impact, and support channels
MFA recoveryBackup authentication method available for all users during MFA re-enrollment

Verify administrative access to both platforms before proceeding:

Terminal window
# For Keycloak target - verify admin CLI access
/opt/keycloak/bin/kcadm.sh config credentials \
--server https://idp.example.org/auth \
--realm master \
--user admin \
--password "${KEYCLOAK_ADMIN_PASSWORD}"
# List realms to confirm access
/opt/keycloak/bin/kcadm.sh get realms --fields realm
# Expected output: list of configured realms
Terminal window
# For Microsoft Entra ID - verify Graph API access
Connect-MgGraph -Scopes "User.ReadWrite.All","Group.ReadWrite.All","Application.ReadWrite.All"
Get-MgOrganization | Select-Object DisplayName, Id
# Expected output: organisation details confirming tenant access
Terminal window
# For Authentik target - verify API access
curl -s -H "Authorization: Bearer ${AUTHENTIK_TOKEN}" \
https://idp.example.org/api/v3/core/users/ | jq '.pagination.count'
# Expected output: current user count (should be minimal for new instance)

Procedure

Phase 1: Discovery and mapping

  1. Export the complete user inventory from the source identity provider. Capture all attributes that applications depend on for authorisation decisions.

    For Microsoft Entra ID source:

Terminal window
# Export all users with relevant attributes
Get-MgUser -All -Property Id,UserPrincipalName,Mail,DisplayName,GivenName,Surname,
Department,JobTitle,OfficeLocation,MobilePhone,AccountEnabled,
OnPremisesSyncEnabled,CreatedDateTime,SignInActivity |
Export-Csv -Path "users_export.csv" -NoTypeInformation
# Count for verification
$users = Import-Csv "users_export.csv"
Write-Host "Exported $($users.Count) users"
# Expected: matches user count in admin console

For Keycloak source:

Terminal window
# Export realm containing users
/opt/keycloak/bin/kc.sh export \
--dir /tmp/export \
--realm production \
--users realm_file
# Verify export
jq '.users | length' /tmp/export/production-realm.json
# Expected: user count matching admin console

For Okta source:

Terminal window
# Export users via API (paginated)
curl -s -H "Authorization: SSWS ${OKTA_API_TOKEN}" \
"https://example.okta.com/api/v1/users?limit=200" \
> users_page1.json
# Continue pagination using 'next' link in response headers
# Combine all pages into single export
jq -s 'add' users_page*.json > users_export.json
jq 'length' users_export.json
# Expected: total user count
  1. Export group memberships with their associated permissions and application assignments. Groups form the basis of access control in the target platform.
Terminal window
# Microsoft Entra ID - export groups and members
$groups = Get-MgGroup -All -Property Id,DisplayName,Description,GroupTypes,
SecurityEnabled,MailEnabled
foreach ($group in $groups) {
$members = Get-MgGroupMember -GroupId $group.Id -All
$group | Add-Member -NotePropertyName "MemberCount" -NotePropertyValue $members.Count
$group | Add-Member -NotePropertyName "Members" -NotePropertyValue ($members.Id -join ";")
}
$groups | Export-Csv -Path "groups_export.csv" -NoTypeInformation
Write-Host "Exported $($groups.Count) groups"
Terminal window
# Keycloak - groups are included in realm export
jq '.groups | length' /tmp/export/production-realm.json
jq '.groups[].name' /tmp/export/production-realm.json
  1. Create the attribute mapping between source and target platforms. Attribute names differ between providers, and applications depend on specific claim names in tokens.

    Document the mapping in a configuration file that migration scripts consume:

attribute_mapping.yaml
user_attributes:
# source_attribute: target_attribute
userPrincipalName: username
mail: email
givenName: firstName
surname: lastName
displayName: displayName
department: attributes.department
jobTitle: attributes.jobTitle
mobilePhone: attributes.mobile
officeLocation: attributes.location
group_mapping:
# source_group_name: target_group_name
"All Staff": "all-staff"
"Finance Team": "finance"
"IT Administrators": "it-admins"
"Field Staff": "field-operations"
# Attributes required by specific applications
application_claims:
grants-system:
- email
- displayName
- attributes.department
- groups
hr-portal:
- email
- firstName
- lastName
- attributes.employeeId
  1. Inventory all applications integrated with the source identity provider. Record the authentication protocol, redirect URIs, and any custom claims each application requires.
Terminal window
# Microsoft Entra ID - export enterprise applications
Get-MgServicePrincipal -All -Property Id,DisplayName,AppId,
SignInAudience,PreferredSingleSignOnMode,LoginUrl |
Where-Object { $_.PreferredSingleSignOnMode -ne $null } |
Export-Csv -Path "applications_export.csv" -NoTypeInformation
Terminal window
# Keycloak - list clients (applications)
/opt/keycloak/bin/kcadm.sh get clients -r production \
--fields id,clientId,protocol,enabled,directAccessGrantsEnabled \
> clients_export.json
jq -r '.[] | select(.enabled==true) | .clientId' clients_export.json

For each application, document:

ApplicationProtocolRedirect URIsCustom claimsOwner
Grants ManagementOIDChttps://grants.example.org/callbackdepartment, groupsFinance
HR PortalSAMLhttps://hr.example.org/sso/samlemployeeId, managerHR
Field Data CollectionOIDChttps://collect.example.org/authlocation, roleProgrammes
WikiOIDChttps://wiki.example.org/oauth/callbackdisplayName, emailIT

Phase 2: Target platform configuration

  1. Create the organisational structure in the target platform. This includes realms or tenants, administrative roles, and base configuration.

    For Keycloak:

Terminal window
# Create production realm
/opt/keycloak/bin/kcadm.sh create realms \
-s realm=production \
-s enabled=true \
-s displayName="Production" \
-s loginTheme=organisation-theme \
-s accountTheme=organisation-theme \
-s internationalizationEnabled=true \
-s supportedLocales='["en","fr","es","ar"]' \
-s defaultLocale=en
# Configure token lifespans
/opt/keycloak/bin/kcadm.sh update realms/production \
-s accessTokenLifespan=300 \
-s ssoSessionIdleTimeout=1800 \
-s ssoSessionMaxLifespan=36000 \
-s offlineSessionIdleTimeout=2592000
# Configure password policy
/opt/keycloak/bin/kcadm.sh update realms/production \
-s 'passwordPolicy="length(12) and digits(1) and upperCase(1) and specialChars(1) and notUsername"'

For Authentik:

Terminal window
# Create tenant via API
curl -X POST -H "Authorization: Bearer ${AUTHENTIK_TOKEN}" \
-H "Content-Type: application/json" \
https://idp.example.org/api/v3/core/tenants/ \
-d '{
"domain": "example.org",
"default": true,
"branding_title": "Organisation SSO",
"branding_logo": "/media/branding/logo.png",
"flow_authentication": "default-authentication-flow",
"flow_invalidation": "default-invalidation-flow"
}'
  1. Create groups in the target platform matching the source group structure. Preserve group hierarchies where the target platform supports nesting.
Terminal window
# Keycloak - create groups from mapping
while IFS=: read -r source_name target_name; do
/opt/keycloak/bin/kcadm.sh create groups -r production \
-s name="${target_name}"
echo "Created group: ${target_name}"
done < <(yq '.group_mapping | to_entries | .[] | .key + ":" + .value' attribute_mapping.yaml)
Terminal window
# Authentik - create groups
for group_name in "all-staff" "finance" "it-admins" "field-operations"; do
curl -X POST -H "Authorization: Bearer ${AUTHENTIK_TOKEN}" \
-H "Content-Type: application/json" \
https://idp.example.org/api/v3/core/groups/ \
-d "{\"name\": \"${group_name}\", \"is_superuser\": false}"
done
  1. Configure authentication flows including MFA requirements. Match or improve upon source platform security policies.
Terminal window
# Keycloak - configure browser authentication flow with MFA
# Copy built-in browser flow
/opt/keycloak/bin/kcadm.sh create authentication/flows/browser/copy \
-r production \
-s newName="browser-with-otp"
# Get the flow ID
FLOW_ID=$(/opt/keycloak/bin/kcadm.sh get authentication/flows \
-r production --fields id,alias | \
jq -r '.[] | select(.alias=="browser-with-otp") | .id')
# Add OTP execution
/opt/keycloak/bin/kcadm.sh create authentication/flows/browser-with-otp/executions/execution \
-r production \
-s provider=auth-otp-form \
-s requirement=REQUIRED
# Bind flow to browser
/opt/keycloak/bin/kcadm.sh update realms/production \
-s browserFlow="browser-with-otp"

Phase 3: User migration

  1. Transform user data from source format to target format using the attribute mapping. Generate import files or API payloads.
transform_users.py
#!/usr/bin/env python3
import csv
import json
import yaml
import hashlib
from datetime import datetime
# Load attribute mapping
with open('attribute_mapping.yaml') as f:
mapping = yaml.safe_load(f)
# Load source users
with open('users_export.csv') as f:
source_users = list(csv.DictReader(f))
target_users = []
for user in source_users:
if user['AccountEnabled'] != 'True':
continue # Skip disabled accounts
target_user = {
'username': user['UserPrincipalName'].split('@')[0].lower(),
'email': user['Mail'],
'firstName': user['GivenName'],
'lastName': user['Surname'],
'enabled': True,
'emailVerified': True,
'attributes': {
'department': [user['Department']] if user['Department'] else [],
'jobTitle': [user['JobTitle']] if user['JobTitle'] else [],
'sourceId': [user['Id']], # Preserve source ID for correlation
'migratedAt': [datetime.utcnow().isoformat()]
},
'requiredActions': ['CONFIGURE_TOTP'] # Force MFA setup on first login
}
target_users.append(target_user)
# Write Keycloak import format
realm_import = {
'realm': 'production',
'users': target_users
}
with open('users_import.json', 'w') as f:
json.dump(realm_import, f, indent=2)
print(f"Transformed {len(target_users)} users for import")

Run the transformation:

Terminal window
python3 transform_users.py
# Expected output: Transformed 847 users for import
  1. Import users into the target platform. For large migrations (over 1000 users), batch the import to avoid timeouts.
Terminal window
# Keycloak - import users via partial import
/opt/keycloak/bin/kc.sh import \
--file users_import.json \
--override false
# Verify import count
/opt/keycloak/bin/kcadm.sh get users -r production --fields username | \
jq 'length'
# Expected: matches source enabled user count (847)
Terminal window
# Authentik - import via API (batched)
jq -c '.users[]' users_import.json | while read -r user; do
curl -X POST -H "Authorization: Bearer ${AUTHENTIK_TOKEN}" \
-H "Content-Type: application/json" \
https://idp.example.org/api/v3/core/users/ \
-d "${user}"
sleep 0.1 # Rate limiting
done

Password handling

Passwords cannot be migrated between most identity providers due to incompatible hashing algorithms. Users must reset passwords or use an alternative first-login mechanism. See Step 11 for password handling strategies.

  1. Assign users to groups based on source group memberships. This restores access control after migration.
Terminal window
# Keycloak - assign group memberships
# Load group membership mapping from source export
while IFS=, read -r username group_name; do
# Get user ID
USER_ID=$(/opt/keycloak/bin/kcadm.sh get users -r production \
-q username="${username}" --fields id | jq -r '.[0].id')
# Get group ID
GROUP_ID=$(/opt/keycloak/bin/kcadm.sh get groups -r production \
-q search="${group_name}" --fields id | jq -r '.[0].id')
if [ -n "${USER_ID}" ] && [ -n "${GROUP_ID}" ]; then
/opt/keycloak/bin/kcadm.sh update users/${USER_ID}/groups/${GROUP_ID} \
-r production -s realm=production -s userId=${USER_ID} -s groupId=${GROUP_ID} -n
fi
done < group_memberships.csv
  1. Configure password handling for migrated users. Three strategies exist, each with different user experience and security implications.

    Strategy A: Forced password reset on first login

    Users receive a password reset link and must set a new password before accessing any application. This approach provides the cleanest security posture but requires all users to take action.

Terminal window
# Keycloak - add required action to all migrated users
/opt/keycloak/bin/kcadm.sh get users -r production --fields id | \
jq -r '.[].id' | while read -r user_id; do
/opt/keycloak/bin/kcadm.sh update users/${user_id} -r production \
-s 'requiredActions=["UPDATE_PASSWORD"]'
done

Strategy B: Password synchronisation during coexistence

During the coexistence period, authenticate against the source platform and capture passwords to set in the target. This requires a custom authentication flow and should only be used when forced reset is operationally unacceptable.

Strategy C: Temporary passwords with immediate reset

Generate unique temporary passwords, distribute securely to each user, and require immediate change. Suitable for small migrations under 100 users.

generate_temp_passwords.py
import secrets
import string
import csv
def generate_password(length=16):
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
return ''.join(secrets.choice(alphabet) for _ in range(length))
with open('users_import.json') as f:
users = json.load(f)['users']
with open('temp_passwords.csv', 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow(['username', 'email', 'temp_password'])
for user in users:
temp_pw = generate_password()
writer.writerow([user['username'], user['email'], temp_pw])
# Encrypt the file before distribution
# gpg -c temp_passwords.csv

Phase 4: Application integration

  1. Configure each application in the target identity provider. Create client registrations with matching redirect URIs and claim configurations.
Terminal window
# Keycloak - create OIDC client for grants management system
/opt/keycloak/bin/kcadm.sh create clients -r production \
-s clientId=grants-management \
-s enabled=true \
-s protocol=openid-connect \
-s publicClient=false \
-s 'redirectUris=["https://grants.example.org/callback","https://grants.example.org/silent-refresh"]' \
-s 'webOrigins=["https://grants.example.org"]' \
-s standardFlowEnabled=true \
-s directAccessGrantsEnabled=false \
-s serviceAccountsEnabled=false
# Get client ID for secret generation
CLIENT_UUID=$(/opt/keycloak/bin/kcadm.sh get clients -r production \
-q clientId=grants-management --fields id | jq -r '.[0].id')
# Generate client secret
/opt/keycloak/bin/kcadm.sh create clients/${CLIENT_UUID}/client-secret -r production
# Retrieve secret for application configuration
/opt/keycloak/bin/kcadm.sh get clients/${CLIENT_UUID}/client-secret -r production
Terminal window
# Keycloak - create SAML client for HR portal
/opt/keycloak/bin/kcadm.sh create clients -r production \
-s clientId=https://hr.example.org \
-s enabled=true \
-s protocol=saml \
-s 'attributes={"saml.assertion.signature":"true","saml.server.signature":"true","saml.signature.algorithm":"RSA_SHA256","saml.force.post.binding":"true","saml_name_id_format":"email"}' \
-s 'redirectUris=["https://hr.example.org/sso/saml/*"]' \
-s baseUrl=https://hr.example.org \
-s adminUrl=https://hr.example.org/sso/saml
  1. Configure custom claim mappings for applications requiring non-standard attributes.
Terminal window
# Keycloak - add department claim to grants-management client
CLIENT_UUID=$(/opt/keycloak/bin/kcadm.sh get clients -r production \
-q clientId=grants-management --fields id | jq -r '.[0].id')
/opt/keycloak/bin/kcadm.sh create clients/${CLIENT_UUID}/protocol-mappers/models \
-r production \
-s name=department \
-s protocol=openid-connect \
-s protocolMapper=oidc-usermodel-attribute-mapper \
-s 'config={"claim.name":"department","user.attribute":"department","id.token.claim":"true","access.token.claim":"true","userinfo.token.claim":"true","jsonType.label":"String"}'
# Add group membership claim
/opt/keycloak/bin/kcadm.sh create clients/${CLIENT_UUID}/protocol-mappers/models \
-r production \
-s name=groups \
-s protocol=openid-connect \
-s protocolMapper=oidc-group-membership-mapper \
-s 'config={"claim.name":"groups","full.path":"false","id.token.claim":"true","access.token.claim":"true","userinfo.token.claim":"true"}'
  1. Update application configurations to use the target identity provider. Perform this during a maintenance window for each application.

    For OIDC applications, update these configuration values:

# Application configuration - before migration
oidc:
issuer: https://login.microsoftonline.com/tenant-id/v2.0
client_id: old-client-id
client_secret: old-secret
authorization_endpoint: https://login.microsoftonline.com/tenant-id/oauth2/v2.0/authorize
token_endpoint: https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token
# Application configuration - after migration
oidc:
issuer: https://idp.example.org/realms/production
client_id: grants-management
client_secret: new-generated-secret
authorization_endpoint: https://idp.example.org/realms/production/protocol/openid-connect/auth
token_endpoint: https://idp.example.org/realms/production/protocol/openid-connect/token

For SAML applications, update the IdP metadata URL:

<!-- New IdP metadata location -->
<md:EntityDescriptor entityID="https://idp.example.org/realms/production">
<!-- Application imports metadata from -->
<!-- https://idp.example.org/realms/production/protocol/saml/descriptor -->
</md:EntityDescriptor>

Phase 5: Coexistence and cutover

  1. Configure federation between source and target platforms for the coexistence period. This allows users to authenticate via either platform while applications migrate.

    The coexistence architecture routes authentication based on application migration status:

+----------------------------------------------------------------+
| USER AUTHENTICATION REQUEST |
+----------------------------------------------------------------+
|
+---------------v---------------+
| Application checks |
| migration status |
+---------------+---------------+
|
+---------------------+---------------------+
| |
v v
+---------+---------+ +---------+---------+
| Not migrated | | Migrated |
| | | |
| Redirect to | | Redirect to |
| SOURCE IdP | | TARGET IdP |
+---------+---------+ +---------+---------+
| |
v v
+---------+---------+ +---------+---------+
| Source IdP | | Target IdP |
| authenticates | | authenticates |
+---------+---------+ +---------+---------+
| |
+---------------------+---------------------+
|
v
+---------------+---------------+
| User accesses |
| application |
+-------------------------------+

For Keycloak target with Entra ID source, configure identity brokering:

Terminal window
# Create identity provider for source platform
/opt/keycloak/bin/kcadm.sh create identity-provider/instances -r production \
-s alias=azure-ad \
-s providerId=oidc \
-s enabled=true \
-s 'config={"authorizationUrl":"https://login.microsoftonline.com/tenant-id/oauth2/v2.0/authorize","tokenUrl":"https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token","clientId":"broker-client-id","clientSecret":"broker-secret","defaultScope":"openid email profile"}'
  1. Execute a pilot migration with a test group of 20-50 users representing different roles and application usage patterns.
Terminal window
# Create pilot group
/opt/keycloak/bin/kcadm.sh create groups -r production -s name="migration-pilot"
# Add pilot users (select from different departments)
for username in "jsmith" "agarcia" "mwang" "okonkwo" "dubois"; do
USER_ID=$(/opt/keycloak/bin/kcadm.sh get users -r production \
-q username="${username}" --fields id | jq -r '.[0].id')
GROUP_ID=$(/opt/keycloak/bin/kcadm.sh get groups -r production \
-q search="migration-pilot" --fields id | jq -r '.[0].id')
/opt/keycloak/bin/kcadm.sh update users/${USER_ID}/groups/${GROUP_ID} -r production -n
done

Pilot users test:

  • Password reset flow
  • MFA enrollment
  • Access to each migrated application
  • Mobile device authentication
  • VPN authentication (if applicable)
  1. Execute the production cutover. Migrate applications in dependency order, starting with those that have no downstream dependencies.

    Migration sequence for a typical organisation:

    OrderApplicationDependenciesCutover window
    1Wiki/IntranetNone30 minutes
    2Email (if OIDC)None60 minutes
    3HR PortalNone45 minutes
    4Finance SystemHR Portal (user sync)60 minutes
    5Grants ManagementFinance System45 minutes
    6Field Data CollectionGrants Management30 minutes
    7VPNAll internal apps120 minutes
Terminal window
# Cutover script for each application
# Run during scheduled maintenance window
APP_NAME="grants-management"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
echo "[${TIMESTAMP}] Starting cutover for ${APP_NAME}"
# 1. Notify active users
/opt/scripts/notify_active_users.sh "${APP_NAME}" "Maintenance starting in 5 minutes"
sleep 300
# 2. Put application in maintenance mode
kubectl scale deployment ${APP_NAME} --replicas=0 -n production
# 3. Update application configuration
kubectl create configmap ${APP_NAME}-oidc \
--from-file=oidc-config.yaml \
--dry-run=client -o yaml | kubectl apply -f -
# 4. Restart application with new configuration
kubectl scale deployment ${APP_NAME} --replicas=3 -n production
kubectl rollout status deployment ${APP_NAME} -n production
# 5. Verify authentication
/opt/scripts/test_oidc_flow.sh "${APP_NAME}"
echo "[$(date +%Y%m%d_%H%M%S)] Cutover complete for ${APP_NAME}"
  1. Disable source platform authentication for migrated applications while maintaining the platform for rollback capability.
Terminal window
# Microsoft Entra ID - disable enterprise application
$app = Get-MgServicePrincipal -Filter "displayName eq 'Grants Management'"
Update-MgServicePrincipal -ServicePrincipalId $app.Id -AccountEnabled:$false

Maintain the source platform in read-only mode for 30 days to support rollback if critical issues emerge.

Rollback

If critical issues require reverting to the source platform, execute the rollback within the 72-hour window before source platform accounts are disabled.

  1. Identify the scope of rollback: single application, group of applications, or full migration.

  2. For single application rollback:

Terminal window
# Revert application configuration to source IdP
kubectl create configmap ${APP_NAME}-oidc \
--from-file=oidc-config-source.yaml \
--dry-run=client -o yaml | kubectl apply -f -
kubectl rollout restart deployment ${APP_NAME} -n production
  1. For full migration rollback:
Terminal window
# Re-enable source platform applications
Get-MgServicePrincipal -Filter "startswith(displayName, 'Migrated-')" |
ForEach-Object {
Update-MgServicePrincipal -ServicePrincipalId $_.Id -AccountEnabled:$true
}
  1. Communicate rollback to users with instructions to use their original credentials.

  2. Document rollback reasons and blockers for resolution before reattempting migration.

Verification

After cutover, verify each component of the migration:

User authentication verification:

Terminal window
# Test authentication flow for sample users
for username in "jsmith" "agarcia" "mwang"; do
# Request token using resource owner password grant (test only)
RESPONSE=$(curl -s -X POST \
https://idp.example.org/realms/production/protocol/openid-connect/token \
-d "grant_type=password" \
-d "client_id=test-client" \
-d "client_secret=${TEST_CLIENT_SECRET}" \
-d "username=${username}" \
-d "password=${TEST_PASSWORD}" \
-d "scope=openid")
if echo "${RESPONSE}" | jq -e '.access_token' > /dev/null; then
echo "✓ ${username}: authentication successful"
else
echo "✗ ${username}: authentication failed"
echo "${RESPONSE}" | jq '.error_description'
fi
done

Group membership verification:

Terminal window
# Verify group assignments match source
/opt/keycloak/bin/kcadm.sh get groups -r production --fields name,id | \
jq -r '.[].id' | while read -r group_id; do
GROUP_NAME=$(/opt/keycloak/bin/kcadm.sh get groups/${group_id} -r production --fields name | jq -r '.name')
MEMBER_COUNT=$(/opt/keycloak/bin/kcadm.sh get groups/${group_id}/members -r production | jq 'length')
echo "${GROUP_NAME}: ${MEMBER_COUNT} members"
done
# Compare with source counts
# Expected: counts match within 5% (accounting for disabled accounts)

Application integration verification:

Terminal window
# Verify each application can obtain tokens and validate claims
for app in "grants-management" "hr-portal" "field-collection"; do
# Get application health endpoint that requires authentication
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
"https://${app}.example.org/api/health/auth")
if [ "${HTTP_CODE}" = "200" ]; then
echo "✓ ${app}: integration working"
else
echo "✗ ${app}: integration failed (HTTP ${HTTP_CODE})"
fi
done

MFA enrollment verification:

Terminal window
# Check MFA enrollment status
/opt/keycloak/bin/kcadm.sh get users -r production --fields username,totp | \
jq -r '.[] | select(.totp==true) | .username' | wc -l
# Expected: increases daily during migration period as users enroll
# Users without MFA (may need reminders)
/opt/keycloak/bin/kcadm.sh get users -r production --fields username,totp,email | \
jq -r '.[] | select(.totp==false) | "\(.username),\(.email)"' > users_without_mfa.csv

Troubleshooting

SymptomCauseResolution
User cannot authenticate: “Invalid credentials”Password not migrated or user has not resetDirect user to password reset flow; verify reset email delivery
User cannot authenticate: “Account disabled”User disabled in source and status migratedRe-enable account if user should have access: /opt/keycloak/bin/kcadm.sh update users/${USER_ID} -r production -s enabled=true
Application error: “Invalid redirect URI”Redirect URI mismatch between app config and IdP clientAdd missing redirect URI to client configuration; verify no trailing slashes
Application error: “Token validation failed”Client secret mismatch or wrong issuer URLRegenerate client secret and update application configuration
Missing claims in tokenProtocol mapper not configuredAdd required protocol mapper to client; verify claim name matches application expectation
SAML assertion signature invalidCertificate mismatch or algorithm incompatibilityRe-download IdP metadata; verify signature algorithm matches application requirement (SHA256 vs SHA1)
User missing from target platformTransformation script filtered user (disabled, no email)Check transformation logs; manually import if legitimately needed
Group membership not appliedGroup name mapping mismatchVerify group exists in target with expected name; check case sensitivity
MFA prompt on every loginSession not persisting or SSO session timeout too shortIncrease ssoSessionIdleTimeout; verify cookies not blocked
Federated login loopMisconfigured identity brokeringDisable identity brokering temporarily; verify broker client credentials
Login page displays source IdP brandingCached assets or incorrect theme bindingClear browser cache; verify realm theme configuration
Mobile app authentication failsRedirect URI scheme not registeredAdd mobile app custom scheme to client redirect URIs: org.example.app:/callback
VPN authentication fails after migrationRADIUS/LDAP integration not updatedReconfigure VPN to use new LDAP bind credentials or OIDC resource owner grant
Slow authentication (over 5 seconds)Database performance or network latency to IdPCheck IdP database query performance; verify network path; consider regional deployment
Intermittent “Session expired” errorsClock skew between IdP and application serversSynchronise NTP across all servers; allow 60-second clock skew tolerance in token validation

Post-migration tasks

After successful verification, complete these tasks to finalise the migration:

  1. Decommission source platform (after 30-day observation period):
Terminal window
# Microsoft Entra ID - delete migrated user accounts (irreversible)
# Only after confirming all users successfully authenticate to target
Import-Csv migrated_users.csv | ForEach-Object {
Remove-MgUser -UserId $_.SourceId -Confirm:$false
}
  1. Update documentation: Replace all references to source IdP URLs, procedures, and screenshots.

  2. Train service desk: Provide updated procedures for password reset, account unlock, and MFA reset.

  3. Configure monitoring: Establish alerting for authentication failures, token errors, and service availability.

  4. Schedule access review: Plan first quarterly access certification in new platform within 90 days.

See also