Skip to Content
TutorialsMigrate existing users

Migrate existing users

This tutorial moves authentication facts into Identity while preserving a separate product database. It works when the source backend and Identity run on different hosts: the systems exchange a canonical JSON file and a result mapping over HTTPS. They do not need a shared database or filesystem.

What moves and what stays

DataDestination
email and verification stateIdentity
BCrypt password hashIdentity when supplied
enabled TOTP secret and remaining recovery-code hashesIdentity when supplied
explicit Google, Apple or other upstream linksIdentity when supplied
product account IDremains in the source backend as externalId
organizations, subscriptions, product roles and business dataremain in the product backend
final issuer + subject bindingstored by the product backend after import
Security boundary

The authoritative reconciliation key is (source, externalId) -> (issuer, subject). Email is useful for drift detection but must never replace that mapping.

Before you start

  • Create the target tenant and freeze its final issuer.
  • Confirm that the source password hashes use BCrypt when passwords are included.
  • Choose one stable source name, such as customer-backend-prod.
  • Take a consistent database snapshot.
  • Prevent the export artifact from entering Git, logs or world-readable storage.
  • Give the operator identity.admin.users.import and its matching live permission.

Authenticate identityctl against the control tenant:

export IDENTITYCTL_ISSUER='https://auth.identity.lookandtry.com' export IDENTITYCTL_SESSION_FILE="$HOME/.config/lookandtry/identityctl/prod.json" ./identityctl login ./identityctl status

1. Export from the source database

Run a source-owned exporter against a consistent snapshot. Produce stable external IDs and preserve only authentication facts that Identity supports.

Convert each batch into schema version 1:

{ "schemaVersion": 1, "source": "customer-backend-prod", "mode": "VALIDATE_ONLY", "idempotencyKey": "customer-2026-10-13-validate-0001", "records": [ { "externalId": "42", "email": "ada@example.com", "emailVerified": true, "active": true, "password": { "algorithm": "BCRYPT", "hash": "$2a$12$REDACTED_SOURCE_HASH" }, "mfa": null, "upstreamIdentities": [] } ] }

An import job accepts at most 500 records. The identityctl input file must also be a regular, non-symlink file from 1 byte through 512 KiB. Split larger exports deterministically and give every batch its own idempotency key.

Do not transform BCrypt hashes into plaintext. Do not invent upstream links from matching email addresses.

2. Validate without creating users

tenant_id='f351511c-11e4-408f-b069-1c58baf673af' ./identityctl users import \ "$tenant_id" \ customer.validate.0001.json

The command prints the accepted job. Keep its id.

Poll the job:

./identityctl users imports "$tenant_id" 0

Continue until its status is terminal:

StatusMeaning
QUEUEDaccepted and waiting
RUNNINGrecords are being processed
SUCCEEDEDevery record validated
COMPLETED_WITH_ERRORSprocessing ended with one or more rejected records
FAILEDthe job could not complete
CANCELLEDan operator cancelled the job

Validation is successful only when status is SUCCEEDED and processedItems, succeededItems and totalItems are equal.

3. Inspect every result page

./identityctl users import-results \ "$tenant_id" \ VALIDATION_JOB_ID \ 0

Read content and increment the page number while last is false. Verify:

  • exactly one result exists for every exported externalId;
  • every result has status: "SUCCEEDED";
  • no unexpected email normalization occurred;
  • no result has an errorCode.

A validation job does not create new accounts, so a new record can have a null subject at this stage.

Do not continue by checking only job totals. Record-level results are the evidence that the source batch and target interpretation agree.

4. Submit the creation batch

Create a second document with the same source records, mode: "CREATE_ONLY" and a new idempotency key:

{ "schemaVersion": 1, "source": "customer-backend-prod", "mode": "CREATE_ONLY", "idempotencyKey": "customer-2026-10-13-create-0001", "records": [ { "externalId": "42", "email": "ada@example.com", "emailVerified": true, "active": true, "password": { "algorithm": "BCRYPT", "hash": "$2a$12$REDACTED_SOURCE_HASH" }, "mfa": null, "upstreamIdentities": [] } ] }
./identityctl users import \ "$tenant_id" \ customer.create.0001.json

Reusing the validation key with a different mode is a conflict. Replaying the creation request with the same key and identical content addresses the same logical operation.

5. Build the authoritative mapping

Wait for SUCCEEDED and fetch every creation result page. Each successful record now has a non-null subject:

{ "position": 0, "externalId": "42", "email": "ada@example.com", "status": "SUCCEEDED", "subject": "usr_01JY7N8SV4B30TQGB8Y3YH5J9M", "errorCode": null, "processedAt": "2026-10-13T09:00:02.118Z" }

Aggregate a private mapping file and check that external IDs and subjects are unique. The source backend then writes, in one controlled migration:

local_account_id = 42 identity_issuer = https://auth.example.com identity_subject = usr_01JY7N8SV4B30TQGB8Y3YH5J9M

The binding migration must preserve existing organizations, memberships, product roles and business data.

6. Verify before cutover

For representative accounts, prove all of these conditions:

  1. the existing password authenticates against Identity;
  2. an imported TOTP factor and remaining recovery code work when present;
  3. an imported upstream account resolves to the same Identity subject;
  4. the product backend resolves the access token by exact issuer + subject;
  5. the user retains the expected product organizations and permissions;
  6. disabled source accounts cannot authenticate;
  7. every source record has exactly one successful result.

Only then switch the product’s login entry point to Identity. Keep cutover separate from import so it can be scheduled and observed independently.

Separate VPS workflow

When the source backend and Identity are on different VPS instances:

  1. export on the source host;
  2. encrypt or transfer the private canonical batch through an approved channel;
  3. run identityctl from any operator host that can reach the control issuer;
  4. retrieve the result mapping on that operator host;
  5. transfer the mapping to the product migration process;
  6. apply the binding in the product database.

Identity never connects to the product database. The product never needs Identity database access.

Recovery rules

  • Exact idempotent replay is safe.
  • A reused idempotency key with different content must stop the run.
  • A partially failed batch is reconciled from result pages, not guessed from email.
  • Never run product cutover for records without an authoritative subject.
  • Keep source credentials active until the agreed rollback window ends.
  • Delete private migration artifacts according to the migration retention policy after verification.

See bulk transfer response contracts and identityctl operations.