Skip to content

Product documentation

Migrate your users to Thoryn

Move an existing user base off Auth0, Okta, Cognito, or Keycloak — bulk password-hash import, lazy trickle migration that verifies at first login, and federate-then-migrate coexistence, with an honest account of what carries across and what does not.

Migrate your users to Thoryn

Moving an existing user base to a new identity platform has exactly one hard problem: the passwords. Everything else — emails, names, locales — is a CSV. Passwords are one-way hashes, and whether you can bring them across decides which migration you run and whether your users notice at all.

Thoryn supports three migration shapes. Which one you use is decided by a single question: will your current provider give you the password hashes?

  • It will (self-hosted Keycloak, a Spring app, a database dump you control) → bulk import the hashes. Users log in with their existing password on day one. No reset email.
  • It will not (Auth0, AWS Cognito, Firebase — none of them export the stored hash, by design) → trickle migration. Thoryn verifies each password against your old provider the first time that user signs in, then stores it locally. Users migrate as they show up.
  • You are not ready to move the credential at allfederate and coexist. Keep the old IdP as a federation member and cut over later.

These combine. A realistic Auth0 migration runs trickle for the active users and a bulk import of profile shells for the rest.

Choose your strategy

Bulk importTrickle migrationFederate and coexist
Needs hashes from the sourceYesNoNo
User sees a password resetNoNoNo
Old IdP stays upNoYes, until cutoverYes, indefinitely
Users who never log inMigratedNever migratedNever migrated
Thoryn handles the plaintextNoYes, transiently (see below)No
Migrates onYour scheduleFirst login per userNever — sign-in stays remote
Works withAny source that exports bcrypt or argon2; pbkdf2 / scrypt with caveatsAuth0 and AWS Cognito, todayAny OIDC or SAML IdP
Surface/api/v1/users/import (tenant admin)/api/v1/migration-sources (tenant admin)/api/v1/federation-members (tenant admin)

Which one is not a preference — it is a property of your source. Auth0 and Cognito never expose a stored password hash. If you are on either, bulk import can move profiles but not credentials, and trickle is the only path that avoids a reset for everyone.

Strategy A — bulk import with password hashes

You export your users with their hashes, and Thoryn stores each hash verbatim. At that user's first login, Thoryn's DelegatingPasswordEncoder reads the hash's algorithm prefix and verifies the typed password against it directly. Nothing is re-hashed, no reset email is sent, and the user never learns a migration happened.

Two endpoints take the same request body:

  • POST /api/v1/users/importsynchronous, up to 500 rows, returns the per-row outcome immediately.
  • POST /api/v1/users/import/jobsasynchronous, up to 10,000 rows by default (oauthy.identity.import.jobs.max-rows), returns 202 and a job id you poll at GET /api/v1/users/import/jobs/{id}.

Both are self-service: they sit on product-api behind the api-gateway like every other customer-plane endpoint, gated by the tenant:users.import scope, and import into the tenant your token's tnt claim names — you cannot address another tenant, and a job id belonging to one is a 404. Under the covers product-api proxies identity-service, the federation member that owns user rows; the hub stores no user data and has no import surface.

tenant:users.import is a scope of its own, deliberately not folded into tenant:users.write. Bulk-writing your whole directory — with pre-hashed credentials that grant sign-in — is a much larger blast radius than single-user CRUD, so it is separable: grant it to a one-off migration client, then revoke it, without giving up day-to-day user management. Ask your Thoryn contact to enable it if your token does not carry it.

Batch size on the wire. The gateway raises its request-body cap to 8 MB on the import path (/api/v1/users/import), so the full 10,000-row async job is reachable even when every row carries a password hash (a hashed 10,000-row batch is ~2.8–5.7 MB). Every other endpoint keeps a tight 1 MB cap. If a single batch still exceeds 8 MB, split it across jobs — the import is idempotent-by-skip, so overlapping batches are safe — and you get an RFC 9457 413 (payload_too_large) with a split-the-batch hint, not a bare status.

Passwords — exactly what is supported

This is the part that decides whether the migration is invisible or painful, so it is worth being precise.

Four algorithms verify. The import checks every supplied hash before storing it — both that its algorithm is one Thoryn can verify and that the hash itself is one the verifying encoder can actually use. A hash that would fail at login is rejected at import, as a per-row error, while you can still do something about it:

algorithmStored prefixWhat you can bring across
bcrypt{bcrypt}Any cost factor. The cost lives in the hash string, so any bcrypt hash works. $2a, $2b and $2y all verify.
argon2{argon2}Any parameters. Memory, iterations, parallelism, hash length and salt length are all read from the hash. Argon2i, Argon2d and Argon2id.
scrypt{scrypt}Any CPU cost, memory cost, parallelisation and salt length — but the derived key must be 32 bytes. See below.
pbkdf2{pbkdf2}Only Spring Security v5.8 defaults, and you must declare them. Nothing about a pbkdf2 hash reveals its own parameters.

{noop} and other reversible or weak encoders are deliberately excluded — an accidental {noop} row must never verify a plaintext. A row naming any unsupported algorithm is rejected with unsupported_password_algorithm and the rest of the batch continues.

Two accepted shapes. Either hand Thoryn the raw hash plus its algorithm:

{ "algorithm": "bcrypt", "hash": "$2b$12$K4t.../8ZQe3sTvW1u" }

...or a hash that already carries a Spring {id} prefix, which is stored as-is:

{ "hash": "{argon2}$argon2id$v=19$m=16384,t=2,p=1$c29tZXNhbHQ$..." }

A prefixed hash still has its enclosed id checked against the supported set. Whichever shape you use, the bytes after the prefix are kept exactly as you sent them.

No plaintext on this path. ImportUserRow has no plaintext password field at all — imports carry hashes, not passwords. If you have plaintext, you do not have a migration problem; create the users normally.

The parameter trap

A hash only verifies if the encoder checking it is configured the same way as the one that produced it. Whether that is your problem depends entirely on whether the algorithm writes its own parameters into the hash string — and the answer is not the one most people assume.

Thoryn's pbkdf2, scrypt and argon2 sub-encoders are each pinned to Spring Security's defaultsForSpringSecurity_v5_8() — one fixed parameter set each. What that actually costs you, measured against those encoders:

AlgorithmParameters read from the hashParameters taken from Thoryn's encoder
bcryptcost factor, $2a / $2b / $2y
argon2memory, iterations, parallelism, hash length, salt length, variant
scryptCPU cost, memory cost, parallelisation, salt lengthderived-key length (32 bytes)
pbkdf2nothingiterations, PRF, salt length, hash width, secret, encoding

So bcrypt and argon2 carry across whatever they were tuned to. scrypt carries across on four dimensions out of five. pbkdf2 carries across nothing: it stores a bare salt || hash with no parameters embedded at all, which is why it is the one algorithm you have to describe.

This used to be a trap and is now a rejection. Before, a mismatched hash imported as CREATED with clean counters and then failed at every login — a green import and a locked-out user base, with no signal connecting the two. The import now establishes that a hash is usable before storing it, and rejects the row if it cannot. A CREATED row is therefore a far stronger signal than it was — though still not a substitute for round-tripping one real login, since the argon2 structural check is deliberately loose: it catches the common no-PHC-framing export bug, not every corrupt-internals case (Known gaps).

Declare your pbkdf2 parameters. Since nothing in the hash reveals them, you state them and Thoryn checks your statement against what it will verify with. They must match exactly:

{
  "algorithm": "pbkdf2",
  "hash": "5f3a...c8e1",
  "parameters": {
    "iterations": 310000,
    "secretKeyFactoryAlgorithm": "PBKDF2WithHmacSHA256",
    "saltLength": 16,
    "hashWidth": 256,
    "encoding": "hex"
  }
}

Those five values are Thoryn's pinned pbkdf2 configuration — copy the block as-is if your source matches. saltLength is in bytes, hashWidth in bits, and the secret must be empty (a peppered hash cannot verify here and cannot be expressed). Anything that differs is rejected with password_hash_parameters_mismatch; omitting the block is rejected with missing_password_hash_parameters. Do not send parameters for bcrypt, scrypt or argon2 — those read their parameters from the hash, so a declaration would be a false belief that Thoryn is honouring it, and is rejected with unexpected_password_hash_parameters.

If your hashes cannot come across, you have three options, in order of preference:

  1. Re-hash at source into bcrypt — possible only where you can see the plaintext at login. bcrypt is the format with no caveats at all.
  2. Run trickle migration — verifies against your old provider at first login, so the hash never has to move.
  3. Import profile shells and send everyone through password reset — the honest fallback, and the one your users will notice.

Import a batch synchronously

Up to 500 rows. email is the only required field on a row.

curl -X POST https://api.thoryn.org/api/v1/users/import \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "users": [
      {
        "email": "ada@example.com",
        "password": { "algorithm": "bcrypt", "hash": "$2b$12$K4t.../8ZQe3sTvW1u" },
        "givenName": "Ada",
        "familyName": "Lovelace",
        "locale": "en-GB",
        "emailVerified": true,
        "externalId": "auth0|5f8a2b1c"
      },
      { "email": "grace@example.com" }
    ]
  }'

Every field except email is optional. password may be omitted entirely — that creates a password-less user, which is what you want for a profile shell that will sign in through federation, or for a user you intend to send through password reset.

The response summarises the batch and reports every row, in input order:

{
  "imported": 1,
  "skipped": 1,
  "failed": 0,
  "results": [
    { "email": "ada@example.com", "status": "CREATED" },
    { "email": "grace@example.com", "status": "SKIPPED_DUPLICATE" }
  ]
}

Partial success is the contract. One bad row never fails the batch — it yields an ERROR result with a stable reason code and processing continues. Only two conditions reject the batch whole, both with an RFC 9457 400:

ConditionerrorCode
users is emptyempty_batch
More than 500 rows (sync) or more than max-rows (async)batch_too_large

Import a large batch as a job

Over 500 rows, submit a job. Same body, different endpoint:

curl -X POST https://api.thoryn.org/api/v1/users/import/jobs \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d @users.json

You get 202 Accepted, a Location header pointing at the job, and the job body. Poll it:

curl https://api.thoryn.org/api/v1/users/import/jobs/<job-id> \
  -H "Authorization: Bearer $TOKEN"
{
  "id": "9f1c...",
  "status": "in_progress",
  "rowsTotal": 8400,
  "rowsProcessed": 3200,
  "imported": 3105,
  "skipped": 92,
  "failed": 3,
  "errorMessage": null,
  "createdAt": "2026-07-17T09:12:04Z",
  "startedAt": "2026-07-17T09:12:09Z",
  "completedAt": null,
  "errors": [
    { "rowIndex": 118, "email": "bad@example.com", "reason": "unsupported_password_algorithm" }
  ],
  "errorsTruncated": false
}

Status moves through pendingin_progresscompleted, failed, or cancelled. A poller drains pending jobs on a fixed delay (5s by default), feeding rows through the same import path the synchronous endpoint uses, in chunks of 100, persisting the counters after each chunk — so rowsProcessed climbs live on a large job.

Per-row errors are capped at 500 stored rows per job (oauthy.identity.import.jobs.max-row-errors). The failed counter stays exact beyond the cap; errorsTruncated flips to true so you know the errors array is a sample, not the whole story.

A job that keeps failing transiently is retried, up to max-attempts (5) before it flips to failed with a stable errorMessage. An in_progress job with no progress for 30 minutes (stale-after) is presumed orphaned by a dead runner and re-queued.

List and cancel jobs

Lost a job id, or want to see everything you have running? List your tenant's jobs — cursor-paginated, newest first:

curl https://api.thoryn.org/api/v1/users/import/jobs \
  -H "Authorization: Bearer $TOKEN"
{
  "data": [
    { "id": "9f1c...", "status": "in_progress", "rowsTotal": 8400, "rowsProcessed": 3200 },
    { "id": "7a2b...", "status": "completed", "rowsTotal": 40, "rowsProcessed": 40 }
  ],
  "pagination": { "cursor": null, "hasMore": false }
}

limit defaults to 20 (max 100); pass the returned pagination.cursor back as ?cursor= for the next page. The list view omits per-row errors to keep a page of jobs cheap — poll GET /api/v1/users/import/jobs/{id} for those.

Submitted the wrong file, or watching a job import rows you did not mean to? Cancel it while it is still pending or in_progress:

curl -X POST https://api.thoryn.org/api/v1/users/import/jobs/<job-id>/cancel \
  -H "Authorization: Bearer $TOKEN"

The poller checks between chunks and stops as soon as it notices. Rows already processed before that point stay imported — cancel does not roll anything back, and a resubmit of the same batch is safe either way (idempotent-by-skip). Rows not yet reached are never touched. A successful cancel returns the job with status: "cancelled", and its encrypted payload is wiped immediately, the same as a completed or failed job. Cancelling a job that has already finished — completed, failed, or already cancelled — is a 409 job_not_cancellable: cancel is only for jobs still running.

Idempotency and re-running

The import is idempotent by skip. A row whose email already exists in the tenant is left completely untouched and reported SKIPPED_DUPLICATE — never overwritten, never merged. That holds on the race too: a duplicate-key violation from a concurrent insert is caught and reported as skipped rather than failed.

The practical consequence: re-running the same batch is safe. It is also how the job poller's stale-job re-queue is safe — rows already imported come back as skipped. If you need to change an existing user, that is an update, not an import; use the users API.

Duplicates within a single batch behave the same way: the first row wins, later rows with the same email are skipped.

Row reason codes

When a row's status is ERROR, reason is one of:

reasonMeaning
missing_emailemail was absent, empty, or whitespace.
missing_password_hashA password object was supplied with no hash.
malformed_password_hashThe hash's {id} prefix is unparseable, the body after it is empty, or the hash is not in a shape the encoder for that algorithm can read.
missing_password_algorithmAn unprefixed hash was supplied with no algorithm.
unsupported_password_algorithmThe algorithm is not one of the four supported ids.
missing_password_hash_parametersA pbkdf2 hash arrived with no parameters block, or an incomplete one. Nothing in a pbkdf2 hash reveals its parameters, so an undeclared one cannot be established as verifiable.
password_hash_parameters_mismatchThe hash's KDF parameters are not the ones Thoryn verifies with: a declared pbkdf2 field differs from the pinned set, or a scrypt hash's derived key is not 32 bytes.
unexpected_password_hash_parametersA parameters block was sent for bcrypt, scrypt or argon2. Those read their parameters from the hash; Thoryn will not honour a declaration and does not pretend to.
import_failedAnything else. The detail stays in the service log and never reaches the response.

The three parameter codes are the ones worth wiring into your migration script: they mean this hash would never have worked, and they are the signal you would otherwise only have got from a user who could not log in.

Reason codes are stable and machine-readable. They never carry the hash, the email domain's internals, or an exception message — the response body is safe to log.

Strategy B — trickle migration (verify at first login)

Auth0, Cognito, and Firebase will not give you the hashes. Trickle migration is the answer that avoids a flag-day reset anyway: the user types their existing password into Thoryn's login page, Thoryn replays it against your old provider server-to-server, and on success creates the local user with that now-verified password stored as a local {bcrypt} hash. Users trickle over as they sign in. Accounts that never log in are never migrated — and never need to be.

Each user contacts the source exactly once, on their first login. Every login after that is fully local.

The trickle trade-off

This is not federation, and the difference matters.

In federation the browser is redirected to Okta or Entra, the password is typed into their page, and Thoryn never sees it. Trickle is the opposite data flow: Thoryn transiently handles the plaintext password to replay it against the source's API. There is no other way to verify a credential the source will not export.

The plaintext is a method-local value — passed to the verify call and to user creation, never logged, never persisted except as the local {bcrypt} hash after a successful verify. It rides the outbound request body over TLS, never a URL parameter. But the handling is real, and it is the reason this is a migration mechanism with a cutover date rather than a permanent runtime dependency.

Configure the source

Customer-plane, tenant-admin, self-service — tenant:migration.write:

curl -X PUT https://<gateway-host>/api/v1/migration-sources \
  -H "Authorization: Bearer $TENANT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "sourceType": "auth0",
    "status": "ACTIVE",
    "mfaPolicy": "FAIL_CLOSED",
    "config": {
      "domain": "your-tenant.eu.auth0.com",
      "clientId": "abc123"
    },
    "secret": { "clientSecret": "..." }
  }'

The tenant is always taken from your token's tnt claim — it is never a path segment, so a caller can only ever address their own tenant's source.

secret is write-only. It is stored in Vault identity-side and never echoed back; GET returns only a vaultSecretPath. Omitting secret on a later update keeps the existing one. The secret bytes are read from Vault for the duration of a single verify call and never cached.

auth0 ships. The connector verifies via Auth0's Resource Owner Password Grant — POST https://<domain>/oauth/token with grant_type=password. The token URL is validated through the platform's SSRF guard before dispatch.

cognito ships too. The connector verifies via Amazon Cognito's unauthenticated app-client InitiateAuth call with AuthFlow=USER_PASSWORD_AUTH (the Cognito analogue of Auth0's password grant) — POST https://cognito-idp.<region>.amazonaws.com/. It needs no AWS SDK and no SigV4: the app client id, and — for a confidential app client — a SECRET_HASH (Base64(HMAC-SHA256(username + clientId, appClientSecret))), are the only credentials. The tenant-supplied region is validated against a strict AWS-region grammar and the endpoint URL is re-checked through the SSRF guard before dispatch. Configure it with:

curl -X PUT https://<gateway-host>/api/v1/migration-sources \
  -H "Authorization: Bearer $TENANT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "sourceType": "cognito",
    "status": "ACTIVE",
    "mfaPolicy": "FAIL_CLOSED",
    "config": {
      "region": "eu-west-1",
      "userPoolId": "eu-west-1_aBcDeF012",
      "appClientId": "1example23clientid456"
    },
    "secret": { "appClientSecret": "..." }
  }'

The app client's USER_PASSWORD_AUTH flow must be enabled. secret.appClientSecret is optional for the lazy verify path — omit it for a public app client (Thoryn then sends no SECRET_HASH); include it for a confidential one. Cognito also supports eager bulk profile import (see Pre-populate the directory below); that path additionally needs AWS IAM credentials in the secret. Only a generic HTTP verify-endpoint now remains not implemented — see Known gaps.

Pre-populate the directory (eager profile import)

Plain trickle only creates a local user the first time that user signs in — until then your Thoryn directory looks empty, which is awkward for admin review, group assignment, or reporting during a migration. The eager profile import fills the directory up front without waiting for logins, while keeping the credential on the trickle path.

Validate before you commit (dry-run). Before running the real import, pass ?dryRun=true to validate the configured source without writing anything — no user is created, no import job is enqueued. Thoryn connects the active source, pulls only the first page, and returns a synchronous report so you can confirm the credentials, region and attribute mapping are right:

curl -X POST "https://<gateway-host>/api/v1/migration-sources/import?dryRun=true" \
  -H "Authorization: Bearer $TENANT_TOKEN"
{
  "sourceType": "auth0",
  "reachable": true,
  "rowsTotal": 4213,
  "sampleCount": 50,
  "sampleFields": [
    { "email": "jane@acme.com", "givenName": "Jane", "familyName": "Doe", "emailVerified": true, "blocked": false }
  ],
  "errors": []
}

reachable: true with a populated sampleFields (mapped profile fields only — never a secret or credential) means the source is wired correctly and you can see how the first users map. A misconfiguration fails closed into the report rather than a 500: reachable: false with the cause in errors (e.g. invalid region, management_api_forbidden, missing aws credentials), so the wizard can surface a fixable message. The dry-run reuses tenant:migration.write (validating and running are one capability), never returns 409 (it enqueues nothing to conflict with), and is the backend for the console migration wizard's validate step. Once the report looks right, run the real import:

curl -X POST https://<gateway-host>/api/v1/migration-sources/import \
  -H "Authorization: Bearer $TENANT_TOKEN"

Thoryn pulls the profiles from the configured source — Auth0's Management API, or Amazon Cognito's admin ListUsers API — and pre-creates each as a credentials-pending user — email, name and profile only. Neither Auth0's export nor Cognito's ListUsers returns password hashes, so no credential is imported: each pre-created user is linked to the same source (migrated_from) and its password migrates on its first login exactly as in plain trickle, through the identical verify path. A source blocked / disabled account is imported suspended. The job runs asynchronously; the 202 returns a job id you poll:

curl https://<gateway-host>/api/v1/migration-sources/import/jobs/<id> \
  -H "Authorization: Bearer $TENANT_TOKEN"

The response carries status (pendingin_progresscompleted/failed) plus live imported / skipped / failed counters. Re-running is idempotent — a user already in the directory is skipped, never duplicated — so a resumed or repeated import is safe. One import runs per tenant at a time; a second start returns 409 import_already_running carrying the running job's id on the activeJobId extension member so you can pick its progress back up. If you lose the job id (a page reload, a fresh session), fetch the tenant's most recent import — the running one while an import is in flight, otherwise the last completed/failed job — without knowing the id:

curl https://<gateway-host>/api/v1/migration-sources/import/jobs/latest \
  -H "Authorization: Bearer $TENANT_TOKEN"

It returns the same job shape as poll-by-id, or 404 job_not_found when the tenant has never run an import. The eager import requires the same ACTIVE source configured above; the tenant's app must be authorised for the Auth0 Management API. Reads use tenant:migration.read; starting an import uses tenant:migration.write — the same scope that configures the source, since configuring and running the migration are one capability.

Any directory size, no truncation. Thoryn sizes the source from its own reported total and picks the pull automatically: up to 1000 users it uses the fast Management API list (GET /api/v2/users, paged); past that — where the list endpoint can only reach the first 1000 — it switches to Auth0's bulk export job (POST /api/v2/jobs/users-exports, then polls the job and streams the gzipped result) so the whole directory is imported. The export download is streamed in bounded chunks across the poller's ticks — each tick parses the next page of the gzipped file and advances a line-offset checkpoint — so a directory of any size imports in constant memory, with no fixed profile ceiling. Both paths feed the same idempotent, resumable job, so a large export that is interrupted resumes where it left off and never duplicates a user. The tenant's app must be authorised for the Auth0 Management API — for a large directory that includes the read/create scopes the export job needs (read:users, create:users_exports, read:users_app_metadata).

Cognito eager import. For a cognito source the pull uses Amazon Cognito's admin ListUsers API, paged by its PaginationToken (checkpointed as the same opaque, resumable job cursor). Unlike the lazy InitiateAuth verify, ListUsers is an authenticated admin API that requires AWS SigV4 request signing, so it needs AWS IAM credentials with cognito-idp:ListUsers permission on the user pool. Add them to the source secret alongside the (optional) appClientSecret:

curl -X PUT https://<gateway-host>/api/v1/migration-sources \
  -H "Authorization: Bearer $TENANT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "sourceType": "cognito",
    "status": "ACTIVE",
    "mfaPolicy": "FAIL_CLOSED",
    "config": { "region": "eu-west-1", "userPoolId": "eu-west-1_aBcDeF012", "appClientId": "1example23clientid456" },
    "secret": { "appClientSecret": "...", "accessKeyId": "AKIA...", "secretAccessKey": "..." }
  }'

The IAM credentials are stored in Vault (never in Postgres, never logged) and read only for the duration of each ListUsers call. The region is validated against a strict grammar before it can influence the endpoint host, and passing explicit credentials stops the SDK from probing the EC2 instance-metadata endpoint. Profiles map the same way — email, name, locale, the Cognito sub as the external id, a disabled account imported suspended — and feed the same idempotent, resumable job, so a Cognito import of any size runs in bounded memory and never truncates.

What happens at first login

When a user without a migrated credential signs in and an ACTIVE source is configured — whether they have no local row at all, or a credentials-pending row the eager import pre-created:

  1. A rate-limit budget is consumed before the source is contacted.
  2. Thoryn calls the source with the email and password.
  3. On success, the local user is created with the password stored as {bcrypt}, stamped with migrated_from and migrated_at, and the login proceeds normally.

The outcome mapping is strict — only an accepted credential admits anyone:

Source respondsOutcome
2xx with tokensVerified — user provisioned, login succeeds
4xx with error=mfa_requiredMFA policy decides (below)
Other 4xxInvalid credentials — the standard generic "invalid email or password". No local row is created.
5xx, timeout, connect error, blocked URL, missing secretFail-closed — the login is denied

A source outage never admits anyone. That is deliberate and it is the opposite of convenient: if your old Auth0 tenant is down, not-yet-migrated users cannot sign in. Already-migrated users are unaffected — they are fully local.

Three properties come free from where the hook sits in the login chain:

  • It fires only when there is no local row (migrate-once). An existing user's password is checked locally and never replayed to the source — including when they get it wrong.
  • Lockout and suspension still gate first. A locked or suspended local user is rejected before the migration hook ever runs, so the source is never contacted for such an account.
  • Breach checking still fires. A migrated password is checked against known-breached passwords on that very first login and the user is sent to reset if it matches — a better posture than the source offered.

Tenants with no configured source see zero behaviour change.

The MFA policy

A password-only verify cannot assert a second factor. When the source says MFA is required, mfaPolicy decides:

  • FAIL_CLOSED (default) — the login is denied. The user migrates by a path that re-establishes MFA: password reset, or bulk import plus local re-enrolment. The second factor is never silently stripped.
  • MIGRATE_WITHOUT_MFA — opt-in, per-tenant, and assurance-reducing. The source accepted the first factor; you have chosen to migrate on the password alone. The user is provisioned with a minimal profile, because no ID token is returned on the MFA-required path.

Pick MIGRATE_WITHOUT_MFA only as a deliberate, documented decision. It is a real downgrade in assurance for every account that had MFA at the source.

Brute-force limits

Pre-migration there is no local row, so per-account lockout has nothing to lock. This limiter is the sole brute-force control on the verify path, and it fails closed — including when its Redis backing store errors:

BucketLimit
Per email, per minute5
Per email, per hour20
Per IP, per minute20

Budget is consumed before the source is contacted, so a spray attack cannot use your tenant as an oracle against your old provider.

Cut over

Watch the trickle rate. When it approaches zero, the active population has migrated. Flip the source to DISABLED:

curl -X PUT https://<gateway-host>/api/v1/migration-sources \
  -H "Authorization: Bearer $TENANT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "sourceType": "auth0", "status": "DISABLED", "config": { "domain": "...", "clientId": "..." } }'

Thoryn stops contacting the source entirely; not-yet-migrated logins simply fail as normal sign-in failures. No data is deleted and re-enabling is a flip back to ACTIVE.

DELETE /api/v1/migration-sources removes the config and its Vault secret. It is idempotent. Do that once you are confident, then decommission the old tenant — the long-tail accounts that never logged in are the ones you bulk-import as shells or let go.

Strategy C — federate and coexist

If you are not ready to move credentials at all, keep the old IdP and attach it as a federation member. Users keep signing in there; Thoryn brokers the tokens. Nothing about the credential moves, and there is no cutover date to hit.

This is the right answer when the old IdP is a corporate directory you were never going to retire (Entra, Okta as the workforce IdP), or when you want Thoryn in front of your apps this quarter and the credential migration next quarter. See Add a federation member.

Pair it with SCIM provisioning to get the accounts into Thoryn ahead of the logins — SCIM creates and deactivates users on your IdP's schedule, so the directory is populated and correct before anyone signs in. SCIM and federation are the standard enterprise pair: SCIM makes the user exist, federation authenticates them.

What comes across, and what does not

Be blunt with your own stakeholders about this list. Only the fields below carry.

Bulk import carries:

FieldNotes
emailRequired. The identity key within the tenant.
passwordAs a hash, if your source exports one of the four supported formats — and, for pbkdf2 and scrypt, at parameters Thoryn can verify. See the parameter trap.
givenName, familyName, locale, pictureStraight copy.
emailVerifiedDefaults to false. Set it if your source says verified — otherwise you will re-verify everyone.
externalIdYour source's stable subject id. Worth setting: it is how you reconcile back to the old system, and how federated sign-in later matches the user.
federationProviderThe federation-member slug that owns this user's identity, if any.

Trickle carries the source's subject (onto externalId), email, emailVerified, given name, family name, and locale — best-effort from the ID token. Profile fields are advisory; only the credential verdict decides the login.

Nothing else migrates. None of the following is part of any import path today:

Not migratedWhere it lives instead
App and user metadataSeparate calls to PATCH /api/v1/users/{userId}/metadata/app and .../metadata/user after the users exist.
Roles and permissionsThe RBAC surface — see Authorize with roles, permissions, and relationships. Assign after import.
Organization membershipThe import always creates users at tenant level with no organization. See B2B Organizations.
MFA enrolmentsNothing carries. Users re-enrol. There is no MFA field in any import path, and no provider exports TOTP seeds in a portable form. Plan the re-enrolment prompt — it is the one thing your users will notice.
GroupsProvision through SCIM, or model as organizations.
SessionsNever. Everyone signs in fresh.
Password history, lockout counters, breach stateNever. Thoryn's own breach check runs on the first login instead.

MFA re-enrolment is the honest headline of any migration. Passwords can be invisible; second factors cannot. The trickle path's FAIL_CLOSED default is the product being explicit about this rather than quietly dropping a factor.

If you are migrating an Entra tenant, your Conditional Access policies have a separate one-shot translator — POST /api/v1/migration/entra/ca-policies, which defaults to a dry run and never writes to the hub unless you ask. That is policy, not users; see the Entra CA migration reference.

Verify and roll back

Verify

Do these in order, and do the first one on a batch of one:

  1. Round-trip a real login. Import one user whose password you know, and sign in as them. The import now rejects a hash it could not verify, so a CREATED row is a much stronger signal than it used to be — but this is still the only check that exercises the whole chain end to end, and it costs one user. Do it.
  2. Reconcile the counts. imported + skipped + failed must equal rowsTotal. A large skipped on a first run means the users were already there — check you are not importing into a tenant that has been imported already.
  3. Read the errors array. If errorsTruncated is true, the sample is not the whole story; fix the class of error it reveals and re-run — the re-run is safe.
  4. Check emailVerified landed. It defaults to false. Getting this wrong sends your entire user base through email verification on first login.
  5. For trickle, watch the first real logins. A misconfigured clientId or a wrong secret surfaces as fail-closed denials, not as a config error — the source is unreachable from Thoryn's point of view.

Roll back

There is no "undo import" endpoint, and the honest posture is that you do not need one:

  • Nothing is overwritten. Import only ever creates. An existing user is skipped untouched, so a mistaken re-run cannot corrupt data that was already correct.
  • The old IdP is still there. Nothing in either strategy mutates your source. Bulk import reads an export; trickle only ever verifies against the source and never writes to it. Your rollback is to keep pointing your apps at the old provider.
  • Trickle has a switch. Set status: DISABLED and Thoryn stops contacting the source immediately. No data is deleted; flip back to ACTIVE to resume.
  • A bad batch is cleaned up per user, through the normal users API — deletion is the GDPR lifecycle's job, not the import's.

The sequencing that makes rollback a non-event: import into a tenant your production traffic is not pointed at yet, verify there, then cut traffic over. Keep the old IdP running until the trickle rate hits zero.

Endpoint reference

Bulk import — product-api via the api-gateway, tenant admin (API reference):

MethodPathScopeDescription
POST/api/v1/users/importtenant:users.importImport up to 500 users synchronously. Returns per-row results.
POST/api/v1/users/import/jobstenant:users.importSubmit an async import job (up to max-rows, default 10,000). Returns 202 + job id.
GET/api/v1/users/import/jobstenant:users.importCursor-paginated list of the tenant's jobs, newest first.
GET/api/v1/users/import/jobs/{id}tenant:users.importPoll job status, live counters, and per-row errors.
POST/api/v1/users/import/jobs/{id}/canceltenant:users.importCancel a pending/in_progress job.

The same three operations also exist on identity-service's operator plane (/admin/users/import…, SCOPE_admin) for Thoryn support workflows. They run the same code against the same job model; tenant admins use the /api/v1 surface above.

Trickle migration source — product-api via the api-gateway, tenant admin:

MethodPathScopeDescription
GET/api/v1/migration-sourcestenant:migration.readThe tenant's configured source. 404 when none.
PUT/api/v1/migration-sourcestenant:migration.writeCreate or replace the source. Secret is write-only.
DELETE/api/v1/migration-sourcestenant:migration.writeRemove the source and its Vault secret. Idempotent.
POST/api/v1/migration-sources/importtenant:migration.writeStart an eager profile import (pull profiles, pre-create credentials-pending users). 202 + job id. 409 import_already_running carries the running job's id on the activeJobId extension. Pass ?dryRun=true to instead return a synchronous validation report (reachability + mapped-profile sample + errors) that writes nothing — 200, never 409.
GET/api/v1/migration-sources/import/jobs/latesttenant:migration.readThe tenant's most recent import job — active while one runs, else the last terminal one — for post-reload progress recovery. 404 job_not_found when none ever ran.
GET/api/v1/migration-sources/import/jobs/{id}tenant:migration.readPoll an eager-import job: status + live counters. 404 for another tenant's / unknown id.

Errors on every endpoint above are RFC 9457 problem details. A cross-tenant or unknown job id is 404, never 403 — no existence leak.

errorCodeStatusCause
empty_batch400users was empty.
batch_too_large400Over the row cap. Nothing was processed.
import_unavailable503The job payload could not be encrypted for storage. Nothing was persisted.
job_not_found404Unknown job id, or one belonging to another tenant.
job_not_cancellable409The job is already completed, failed, or already cancelled.
invalid_cursor400The ?cursor= value on a job-list request is malformed, or was minted for a different tenant.
migration_source_not_found404No source configured for this tenant.
export_unsupported400The configured source has no bulk profile-export API (eager import).
import_already_running409An eager profile import is already in progress for this tenant.
facade_not_configured503The migration-source facade is not configured on this deployment.
upstream_identity_error502identity-service could not process the request.

Configuration

Async import jobs, on identity-service:

oauthy:
  identity:
    import:
      jobs:
        enabled: true                    # kill-switch for the poller; default true
        max-rows: 10000                  # per-job row cap; over-cap is rejected whole
        poll-interval-ms: 5000           # fixed delay between drain ticks
        chunk-size: 100                  # rows per progress-update slice
        jobs-per-tick: 3                 # pending jobs drained per tick
        max-row-errors: 500              # stored per-row errors per job; counters stay exact
        max-attempts: 5                  # transient-failure attempts before terminal `failed`
        stale-after: PT30M               # in_progress with no progress → presumed orphaned, re-queued
        transit-key: identity-bulk-import  # Vault Transit key for the payload envelope

The synchronous endpoint's 500-row cap is a compile-time constant, not configuration.

Trickle migration has no service-level configuration — a source is per-tenant runtime config through /api/v1/migration-sources, and the rate limits are constants.

See the identity-service configuration reference for the full property set.

Troubleshooting

missing_password_hash_parameters on every pbkdf2 row. Expected — pbkdf2 hashes must carry a parameters block, because nothing in the hash itself reveals the iteration count or the PRF. Add the block shown in the parameter trap.

password_hash_parameters_mismatch on pbkdf2. Your source is not tuned the way Thoryn's verifier is, so the hash could never have verified. Compare your declaration field by field against the pinned block; the usual culprit is iterations (Spring's own default was 185,000 before v5.8) or an SHA-1 / SHA-512 PRF.

password_hash_parameters_mismatch on scrypt. Your source derives a key of some length other than 32 bytes. This is the one scrypt dimension that does not travel in the hash. Re-hash into bcrypt, or use trickle.

unexpected_password_hash_parameters. You sent a parameters block for bcrypt, scrypt or argon2. Drop it — those algorithms carry their own parameters, whatever they are.

malformed_password_hash on bcrypt. Most likely a $2x$ hash from an old PHP crypt_blowfish source. That variant is genuinely unverifiable here — it is not merely a naming difference. Re-hash, or use trickle.

Users import fine, then cannot log in. This used to be the classic symptom of the parameter trap; the import now rejects those rows instead. The obvious malformed-argon2 export bug — raw base64 with no $argon2id$... framing — is now rejected at import too. If you still see silent lockout, the remaining candidate is an argon2 hash that has valid PHC framing but corrupt internals, which the loose structural check cannot see — so round-trip one such login. Otherwise it is not a hash problem: check the user is not locked, suspended, or being sent through email verification because emailVerified defaulted to false.

unsupported_password_algorithm on every row. Your source's format is not one of the four. If it is a variant name (bcrypt_sha256, PBKDF2-HMAC-SHA512 as a literal string), it will not match — the id must be exactly bcrypt, pbkdf2, scrypt, or argon2, or a {id} prefix enclosing one of those. If your source genuinely uses something else, bulk import cannot move the credential; use trickle or reset-on-first-login.

Everything comes back SKIPPED_DUPLICATE. The users already exist in that tenant. Import never overwrites. If you meant to update them, use the users API.

batch_too_large on a batch under 10,000. You posted to the synchronous endpoint, which caps at 500. Use /api/v1/users/import/jobs.

413 payload_too_large from the gateway. The request body exceeds the gateway's cap for that path — 8 MB on the import path, 1 MB everywhere else. A 10,000-row hashed batch (~2.8–5.7 MB) fits under the import cap; if yours is larger still, split it across jobs (the import is idempotent-by-skip). The gateway returns an RFC 9457 problem-detail carrying errorCode: payload_too_large.

403 on every import call. Your token lacks tenant:users.import. It is a scope of its own, separate from tenant:users.write.

Job stuck at pending. The poller is disabled (oauthy.identity.import.jobs.enabled: false) or not running. Jobs are drained by a scheduled poller with a fleet-wide lock — check the service is up.

409 job_not_cancellable on cancel. The job already reached a terminal state — completed, failed, or a previous cancel already took effect — by the time your cancel request landed. GET the job to see which; there is nothing left to stop.

Job went to failed with payload_unreadable. The encrypted payload could not be decrypted — typically Vault was unreachable across all max-attempts. The job is terminal; re-submit it. This is safe: already-imported rows come back skipped.

import_unavailable (503) on submit. Vault could not encrypt the payload, so the job was not persisted — the platform refuses to store credentials in plaintext rather than accept the batch. Retry once Vault is healthy.

Trickle: every login fails after configuring the source. Fail-closed is doing its job and something upstream is wrong. A wrong clientId, a wrong or missing secret, a domain typo, or an Auth0 tenant that has ROPG disabled all present as source-unreachable or invalid-credentials. Verify the grant works with a direct call to your Auth0 tenant first. Note that already-migrated users are unaffected — if everyone is failing, no one has migrated yet.

Trickle: some users are denied with an MFA error. Expected under the default FAIL_CLOSED policy — those accounts have MFA at the source and a password-only verify cannot assert it. Migrate them by password reset or bulk import plus local re-enrolment. Do not reach for MIGRATE_WITHOUT_MFA reflexively; it strips a factor.

Trickle: logins denied under load. The verify limiter (5/min, 20/hr per email; 20/min per IP) fails closed, including on a Redis error. A burst of first logins from one NAT'd office can hit the per-IP bucket.

A user migrated but has no roles or organization. Working as designed — neither carries. Assign after import.

Security notes

  • Import payloads carry credentials and are encrypted at rest. An async job's body is Vault Transit-encrypted before it is persisted, with the key version recorded so rotation keeps historical envelopes decryptable. The envelope is wiped once the job reaches a terminal state — the hashes' permanent home is the user row, and a second copy of credentials must not linger.
  • If the payload cannot be encrypted, the job is refused, not stored. A 503 beats credentials at rest in plaintext.
  • {noop} and weak encoders are structurally excluded. The supported set is the exact key set of the verifier's encoder map, so an accidental {noop} row can never verify a plaintext.
  • A hash that cannot verify is refused, not stored. The import establishes that the verifying encoder can use a hash before it writes the row — by checking the hash's structure, and for pbkdf2 by requiring a parameter declaration that matches. Storing a credential that can never authenticate anyone is a silent lockout, so the row fails loudly instead. The argon2 structural check is deliberately loose — it catches the common no-framing export bug but not every corrupt-internals case; see Known gaps.
  • Trickle handles plaintext transiently — by necessity, and bounded. Body-only over TLS, never a URL parameter, never logged, never persisted except as the local {bcrypt} hash. The outbound token URL goes through the SSRF guard. This is the one place in the platform where a user's upstream password passes through Thoryn, and it exists because no alternative can verify a hash the source will not export.
  • Source secrets live in Vault, never in a response. The config API takes clientSecret write-only and returns only a Vault path. The bytes are read for a single verify call and never cached.
  • Fail-closed is the rule on the trickle path — source unreachable, MFA unassertable, rate-limit exhausted, and rate-limiter-backing-store-down all deny. An outage of your old provider must never admit anyone.
  • Import responses never leak. Reason codes are stable machine strings; exception detail and hash material stay in the service log.
  • tenant:users.import is a high-blast-radius grant. A token carrying it can write your entire directory, including credentials that grant sign-in. Scope it to the client doing the migration and revoke it when the migration is done — that separability is why it is not part of tenant:users.write. (The operator-plane SCOPE_admin twin on identity-service can create users in any tenant it names; it is not a tenant-admin credential and is not what you integrate against.)
  • Imported users bypass password policy at import time. You are storing a hash your old provider accepted, under whatever rules it enforced. The breach check on first login is what catches a weak migrated password. That bypass is specific to the hash-carrying import path: a plaintext initial password — an admin create (POST /admin/users, SSO-1998) or a self-service register / reset (SSO-60) — is instead checked against the HIBP breach corpus at creation time and rejected outright, because the plaintext is in hand and a human is present to choose another. SCIM-provisioned and imported users, whose credential is a foreign hash (or absent), can only be caught at first login.

Known gaps

Stated plainly so you can plan around them:

  • No console UI for import. The API is self-service; driving it means the API or the CLI, not a screen in the console.
  • pbkdf2 verifies only at Spring Security v5.8 default parameters, and you must declare them. scrypt additionally requires a 32-byte derived key. Those hashes are now rejected rather than silently stored, but they still cannot be migrated — see the parameter trap. bcrypt and argon2 are unaffected at any parameters.
  • argon2 structural validation is loose, not exhaustive. The common export bug — raw base64 with no $argon2id$... PHC framing — is now rejected at import (SSO-2006). But a hash that has valid framing yet corrupt internals (a truncated body, a bad base64 alphabet inside a segment) is not fully validated, because Spring's own argon2 parser is package-private and approximating it risks rejecting hashes that would have worked — a worse outcome. So the encoder can still return "no match" for a framing-valid but internally-broken hash. It is the one argon2 case where the "round-trip one login first" advice still earns its keep.
  • auth0 and cognito are the trickle connectors, and both now support eager bulk profile import — Auth0 via the Management API / bulk export job, Cognito via the admin ListUsers API (SSO-2380; its SigV4-signed admin call is the one place the platform pulls in the AWS SDK). A generic HTTP verify-endpoint is designed but not implemented, so a Firebase (or other non-Auth0/Cognito) tenant still falls back to reset-on-first-login or federation.
  • No MFA-enrolment import, from any source, by any path.
  • No metadata, role, or organization-membership fields on the import row. A guided importer that seeds metadata shells is designed but not implemented.