Identity Service: Switching from Zitadel
This page covers one of the identity provider scenarios: an existing installation that moves from Zitadel to another identity provider (IdP). An installation that runs the Identity Service on Zitadel can move to Keycloak or Microsoft Entra ID without losing anything: people keep their accounts, tenant memberships, roles, keys and agents. Only the place they sign in changes.
The switch is two Helm upgrades. The first, a migration upgrade, prepares every person for the new IdP while people still sign in through Zitadel. The second, the switch upgrade, points the Identity Service at the new IdP. For Entra, you link people to their Entra accounts between the two.
Follow the section for your new IdP:
To go back, see Switching back to Zitadel. Keep Zitadel running afterwards; see Retiring Zitadel.
Before You Switch
These apply whichever IdP you move to:
- Versions.
istari-platform6.4.0 or later, with Identity Service 2.1.0, the chart's default image. Pre-registering people from a CSV, below, needs 2.1.0. Entra needsistari-platform6.5.0 or later. - The Identity Service is already enabled and running on Zitadel (Scenario 10). Do not enable the Identity Service and switch IdPs in the same upgrade.
- Zitadel stays reachable through the migration upgrade.
identity.migrations.runAsJob: true, as in Scenario 10.- Back up the Identity Service database before the migration upgrade.
- Move personal access tokens to keys. Personal access tokens (PATs) are issued by Zitadel and stop working after the switch. Have users and agents that sign in with a PAT exchange it for a key first.
- Plan a cutover window. After the switch, sign-ins go to the new IdP.
Both upgrades below are ordinary helm upgrade runs of your existing release with your existing values files, plus the values each step shows.
Switch to Keycloak
People are matched to their Keycloak accounts by verified email: the first time someone signs in through Keycloak, the email on their Keycloak account connects them to their existing Istari account.
1. Register the Identity Service in Keycloak
In the Keycloak realm your users will sign in to, create a client:
| Setting | Value |
|---|---|
| Client type | OpenID Connect |
| Client authentication | On (a confidential client) |
| Authentication flow | Standard flow |
| Valid redirect URIs | https://api.<customer_istari_fqdn>/identity/callback |
| Valid post logout redirect URIs | https://<customer_istari_fqdn> |
Record the Client ID, and the Client secret from the client's Credentials tab.
2. Create Keycloak Accounts for Your Users
Every person who should keep their Istari account needs a Keycloak account in that realm with:
- the same email address they have in Zitadel, and
- Email verified turned on.
A person whose Keycloak email is missing, different or unverified is not linked to their existing account when they sign in.
People Who Are New to Istari
Everyone who already has an Istari account keeps it through the migration upgrade in step 4. Someone with no Istari account yet who signs in through Keycloak is refused: Keycloak knows nothing about Istari tenants, so the Identity Service does not place new people from it. Pre-register each new person in their tenant before their first sign-in. Their first Keycloak sign-in with that email verified claims the account.
List them in a CSV with a header row:
email,tenant_slug
jane.doe@example.com,acme
sam.lee@example.com,acme
Load the file into a ConfigMap, then run import-humans as a Job on the Identity Service image, first as a dry run:
kubectl create configmap people-to-pre-register -n istari --from-file=people.csv=people.csv \
--dry-run=client -o yaml | kubectl apply -f -
apiVersion: batch/v1
kind: Job
metadata:
name: import-humans
namespace: istari
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
imagePullSecrets: [{ name: docker-pull-secret }]
containers:
- name: import-humans
image: istaridigital.jfrog.io/customer-docker/identity-service:<tag>
command:
- /import-humans
- -file
- /people/people.csv
- -provider
- keycloak
- -database-url-env
- ISTARI_DIGITAL_IDENTITY_SERVICE_DATABASE_URL
- -dry-run
envFrom:
- secretRef: { name: istari-identity }
volumeMounts:
- { name: people, mountPath: /people, readOnly: true }
volumes:
- name: people
configMap: { name: people-to-pre-register }
kubectl delete job -n istari import-humans --ignore-not-found
kubectl apply -f import-humans.yaml
kubectl logs -n istari -f job/import-humans
Replace <tag> with Identity Service 2.1.0 or later. The dry run logs each row it would pre-register and ends with its counts. Then remove -dry-run and run the same commands again; they replace the previous Job and ConfigMap, so a later batch runs the same way with a new CSV. The Job fails if any row fails, so a partial run is never reported as success.
- A row is skipped, and counted, when another record already holds the address or it is pre-registered in another tenant; nothing is overwritten. Re-running is safe.
-provider keycloakis required: a pre-registration is claimed only by a sign-in through the provider it names.- Leave
ENROLLMENT_POLICYat its default,open. Underpre-registered-only, sign-ins from an unmapped Keycloak realm are refused. - The CSV format, including optional name columns, is in the identity-service README under "Pre-registering people from a file".
3. Create the Keycloak Secret
Create a secret with the Identity Service's Keycloak settings. The client secret is read from the terminal and piped in, so it never appears in your shell history or in kubectl's arguments:
read -rs KEYCLOAK_CLIENT_SECRET # paste the client secret, then press Enter; it is not echoed
printf '%s' "$KEYCLOAK_CLIENT_SECRET" | kubectl create secret generic keycloak-identity-service-env -n istari \
--from-literal=ISTARI_DIGITAL_IDENTITY_SERVICE_IDP_PROVIDER=keycloak \
--from-literal=ISTARI_DIGITAL_IDENTITY_SERVICE_OIDC_ISSUER=https://<keycloak_host>/realms/<realm> \
--from-literal=ISTARI_DIGITAL_IDENTITY_SERVICE_OIDC_CLIENT_ID=<client_id> \
--from-file=ISTARI_DIGITAL_IDENTITY_SERVICE_OIDC_CLIENT_SECRET=/dev/stdin \
--from-literal=ISTARI_DIGITAL_IDENTITY_SERVICE_OIDC_CLIENT_AUTH_METHOD=client_secret_basic \
--from-literal="ISTARI_DIGITAL_IDENTITY_SERVICE_OIDC_SCOPES=openid profile email offline_access" \
--from-literal=ISTARI_DIGITAL_IDENTITY_SERVICE_OIDC_PRIVATE_KEY=
unset KEYCLOAK_CLIENT_SECRET
OIDC_PRIVATE_KEY is deliberately empty. The Zitadel secret (zitadel-identity-service-env) stays mounted after the switch and carries Zitadel's application key; the empty value here overrides it, so no Zitadel credential is left in the Keycloak configuration.
The issuer has no trailing slash. Nothing in this secret is used until the switch upgrade.
4. Prepare People for Keycloak (Migration Upgrade)
Upgrade with the migration turned on. The Identity Service stays on Zitadel:
identity:
idpMigration:
enabled: true
to: keycloak
fromIssuer: https://zitadel.<customer_istari_fqdn>
The upgrade prepares each person's account for Keycloak. It only adds, and is safe to run again.
5. Switch to Keycloak (Switch Upgrade)
Upgrade again with the migration turned off and the Identity Service pointed at Keycloak:
identity:
oidc:
provider: keycloak
extraEnvSecrets:
- zitadel-identity-service-env
- keycloak-identity-service-env
- Delete the whole
identity.idpMigrationblock from your values,toandfromIssuerincluded. Left atenabled: true, the migration runs again on every upgrade and fails once Zitadel is retired, and a leftovertooverridesidentity.oidc.providerwhen the chart decides what to run. With the block gone, the chart runs nothing on Keycloak. - Set
identity.oidc.provider: keycloakin values, even though the secret also sets it. The chart reads the provider from values; without it, the upgrade fails. - List
keycloak-identity-service-envlast, so its settings override Zitadel's.
For up to 30 seconds after the upgrade, the outgoing Identity Service pod can still answer and send a sign-in to Zitadel.
6. Verify the Keycloak Switch
- In a private browser window, open
https://<customer_istari_fqdn>. The sign-in page is Keycloak's realm. - Sign in as a migrated user. They see the same tenant, files and roles as before the switch.
Switch to Microsoft Entra ID
Entra does not prove that a person owns their email address, so the Identity Service never uses it to find their account. Instead, each person is connected by their Entra Object ID:
- Existing people are linked before the switch: you pair each person's Zitadel user ID with their Entra Object ID and import the pairs.
- New people are pre-registered by Object ID in their tenant.
An Entra sign-in that reaches neither is refused; nobody is enrolled from Entra on the spot. Each person keeps their own tenant, so an installation with several tenants can move to Entra.
1. Register the Identity Service in Entra
In the Microsoft Entra admin center, open App registrations and register a new application:
- Supported account types: accounts in this organizational directory only (single tenant). A multi-tenant registration also works, but the issuer below must still name your directory.
- Redirect URI: platform Web,
https://api.<customer_istari_fqdn>/identity/callback.
Then:
- Record the Application (client) ID and the Directory (tenant) ID from the app's Overview.
- Under Certificates & secrets, create a client secret and record its Value. A certificate credential does not work.
- Under API permissions, make sure the delegated
openid,profile,emailandoffline_accesspermissions are granted, with consent as your directory's policy requires. No Microsoft Graph administration permissions are needed.
2. Create the Entra Secret
read -rs ENTRA_CLIENT_SECRET # paste the client secret value, then press Enter; it is not echoed
printf '%s' "$ENTRA_CLIENT_SECRET" | kubectl create secret generic entra-identity-service-env -n istari \
--from-literal=ISTARI_DIGITAL_IDENTITY_SERVICE_IDP_PROVIDER=entra \
--from-literal=ISTARI_DIGITAL_IDENTITY_SERVICE_OIDC_ISSUER=https://login.microsoftonline.com/<directory_tenant_id>/v2.0 \
--from-literal=ISTARI_DIGITAL_IDENTITY_SERVICE_OIDC_CLIENT_ID=<application_client_id> \
--from-file=ISTARI_DIGITAL_IDENTITY_SERVICE_OIDC_CLIENT_SECRET=/dev/stdin \
--from-literal=ISTARI_DIGITAL_IDENTITY_SERVICE_OIDC_CLIENT_AUTH_METHOD=client_secret_basic \
--from-literal="ISTARI_DIGITAL_IDENTITY_SERVICE_OIDC_SCOPES=openid profile email offline_access" \
--from-literal=ISTARI_DIGITAL_IDENTITY_SERVICE_OIDC_PRIVATE_KEY=
unset ENTRA_CLIENT_SECRET
- The issuer must name your directory's tenant ID. The tenant-independent
common,organizationsandconsumersauthorities are rejected at startup. OIDC_CLIENT_AUTH_METHODmust beclient_secret_basicorclient_secret_post;private_key_jwtis rejected for Entra.OIDC_PRIVATE_KEYis deliberately empty, for the same reason as for Keycloak.- Do not set
JWT_DEFAULT_TENANT_IDoridentity.oidc.defaultTenantId. Entra maps no tenant; the Identity Service ignores the value and logs a warning.
3. Prepare People for Entra (Migration Upgrade)
Upgrade with the migration turned on. The Identity Service stays on Zitadel:
identity:
idpMigration:
enabled: true
to: entra
fromIssuer: https://zitadel.<customer_istari_fqdn>
The upgrade gives every Zitadel user an Istari account, including anyone added to Zitadel since the Identity Service was enabled, so the next step can link them. It only adds, and is safe to run again.
4. Link People to Their Entra Accounts
For each person, pair their Zitadel user ID (the numeric ID on the user's page in the Zitadel console) with their Entra Object ID (on the user's Overview in the Entra admin center), in a CSV:
old_provider,old_subject,new_provider,new_subject,tenant_slug
zitadel,<zitadel_user_id>,entra,<entra_object_id>,<tenant_slug>
One file can cover every tenant: each person keeps their own. tenant_slug is optional; when given, the row is refused unless it names the person's tenant.
Freeze sign-ins from the dry run until the import finishes, so nothing changes between the check and the write. Load the file and run the importer as a Job, first as a dry run:
kubectl create configmap principal-mappings -n istari --from-file=mappings.csv=principal-mappings.csv \
--dry-run=client -o yaml | kubectl apply -f -
apiVersion: batch/v1
kind: Job
metadata:
name: import-principal-mappings
namespace: istari
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
imagePullSecrets: [{ name: docker-pull-secret }]
containers:
- name: import-principal-mappings
image: istaridigital.jfrog.io/customer-docker/identity-service:<tag>
command:
- /import-principal-mappings
- -file
- /mappings/mappings.csv
- -new-provider
- entra
- -database-url-env
- ISTARI_DIGITAL_IDENTITY_SERVICE_DATABASE_URL
- -dry-run
envFrom:
- secretRef: { name: istari-identity }
volumeMounts:
- { name: mappings, mountPath: /mappings, readOnly: true }
volumes:
- name: mappings
configMap: { name: principal-mappings }
kubectl delete job -n istari import-principal-mappings --ignore-not-found
kubectl apply -f import-principal-mappings.yaml
kubectl logs -n istari -f job/import-principal-mappings
The importer checks the whole file before writing anything. The dry run reports each row it would link, and any row it refuses: a duplicate ID, a person with no Istari account, or a suspended person or tenant. Fix the file until the dry run reports no errors. Then remove -dry-run and run the same commands again; the log reports each row as linked. Re-running is safe.
People Who Are New to Istari (Entra)
Pre-register each new person in their tenant by their Entra Object ID, with the import-humans Job and -provider entra in place of -provider keycloak. The CSV needs an upstream_subject column holding the Object ID:
email,upstream_subject,tenant_slug
jane.doe@example.com,<entra_object_id>,acme
A row without an upstream_subject is skipped: Entra never claims a pre-registration by email alone. For the same reason, a person added with the web app's Add user cannot sign in through Entra; pre-register them by Object ID instead.
5. Switch to Entra (Switch Upgrade)
Upgrade again with the migration turned off and the Identity Service pointed at Entra:
identity:
oidc:
provider: entra
extraEnvSecrets:
- zitadel-identity-service-env
- entra-identity-service-env
- Delete the whole
identity.idpMigrationblock, as for Keycloak. - Set
identity.oidc.provider: entrain values, as for Keycloak. - List
entra-identity-service-envlast.
Under Entra, the platform signs in only through the Identity Service's current endpoints. Leave DISCOVERY_API_VERSION unset: 1 prevents startup.
6. Verify the Entra Switch
- In a private browser window, open
https://<customer_istari_fqdn>. The sign-in page islogin.microsoftonline.comfor your directory. - Sign in as a person you linked in step 4. They see the same tenant, files and roles as before the switch.
Switching Back to Zitadel
To return to Zitadel, upgrade with the Scenario 10 values again: remove identity.oidc.provider (or set it to zitadel) and remove the Keycloak or Entra secret from identity.extraEnvSecrets. People keep their accounts: their Zitadel sign-ins still reach the same accounts. The links made for the other IdP stay in place and do nothing while Zitadel is the IdP.
Retiring Zitadel
Keep Zitadel running after the switch. Parts of the platform's user management still read from it, and it is what switching back returns to. Don't retire it while those parts still depend on it. Before you retire it, also check that:
- everyone you migrated has signed in through the new IdP and reached their existing account, and
- no user or agent still signs in with a personal access token.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
The switch upgrade fails in the istari-identity-idp-migrate Job, calling the new IdP | identity.oidc.provider is not set in values. Set it to keycloak or entra. |
| A later upgrade fails in the migration Job after Zitadel is retired | identity.idpMigration.enabled is still true from the migration upgrade. Remove it. |
| The Identity Service does not start after the switch, citing the client auth method | The new IdP's secret is missing OIDC_CLIENT_SECRET or OIDC_CLIENT_AUTH_METHOD, or is listed before zitadel-identity-service-env, whose Zitadel settings then win. |
| Right after the switch, sign-in still goes to Zitadel | The outgoing pod is still stopping (up to 30 seconds). Try again in a private window. |
| A Keycloak user is refused at sign-in | Either they have no Istari account and were not pre-registered, or their Keycloak email does not match their Zitadel email or is not verified. Pre-register them (People who are new to Istari), or fix their Keycloak email and its Email verified setting. |
| An Entra user is refused at sign-in | They were neither linked nor pre-registered by Object ID. Add their row to the CSV and run the link import again, or pre-register them. |
Entra sign-in fails at the token exchange with AADSTS700027 | OIDC_CLIENT_AUTH_METHOD is private_key_jwt. Use client_secret_basic with a client secret. |