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 all → federate 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 import | Trickle migration | Federate and coexist | |
|---|---|---|---|
| Needs hashes from the source | Yes | No | No |
| User sees a password reset | No | No | No |
| Old IdP stays up | No | Yes, until cutover | Yes, indefinitely |
| Users who never log in | Migrated | Never migrated | Never migrated |
| Thoryn handles the plaintext | No | Yes, transiently (see below) | No |
| Migrates on | Your schedule | First login per user | Never — sign-in stays remote |
| Works with | Any source that exports bcrypt or argon2; pbkdf2 / scrypt with caveats | Auth0 and AWS Cognito, today | Any 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/import— synchronous, up to 500 rows, returns the per-row outcome immediately.POST /api/v1/users/import/jobs— asynchronous, up to 10,000 rows by default (oauthy.identity.import.jobs.max-rows), returns202and a job id you poll atGET /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.importis a scope of its own, deliberately not folded intotenant: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 9457413(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:
algorithm | Stored prefix | What 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:
| Algorithm | Parameters read from the hash | Parameters taken from Thoryn's encoder |
|---|---|---|
bcrypt | cost factor, $2a / $2b / $2y | — |
argon2 | memory, iterations, parallelism, hash length, salt length, variant | — |
scrypt | CPU cost, memory cost, parallelisation, salt length | derived-key length (32 bytes) |
pbkdf2 | nothing | iterations, 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
CREATEDwith 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. ACREATEDrow is therefore a far stronger signal than it was — though still not a substitute for round-tripping one real login, since theargon2structural 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:
- Re-hash at source into
bcrypt— possible only where you can see the plaintext at login.bcryptis the format with no caveats at all. - Run trickle migration — verifies against your old provider at first login, so the hash never has to move.
- 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:
| Condition | errorCode |
|---|---|
users is empty | empty_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.jsonYou 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 pending → in_progress → completed, 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:
reason | Meaning |
|---|---|
missing_email | email was absent, empty, or whitespace. |
missing_password_hash | A password object was supplied with no hash. |
malformed_password_hash | The 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_algorithm | An unprefixed hash was supplied with no algorithm. |
unsupported_password_algorithm | The algorithm is not one of the four supported ids. |
missing_password_hash_parameters | A 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_mismatch | The 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_parameters | A 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_failed | Anything 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 (pending → in_progress → completed/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:
- A rate-limit budget is consumed before the source is contacted.
- Thoryn calls the source with the email and password.
- On success, the local user is created with the password stored as
{bcrypt}, stamped withmigrated_fromandmigrated_at, and the login proceeds normally.
The outcome mapping is strict — only an accepted credential admits anyone:
| Source responds | Outcome |
|---|---|
2xx with tokens | Verified — user provisioned, login succeeds |
4xx with error=mfa_required | MFA policy decides (below) |
Other 4xx | Invalid credentials — the standard generic "invalid email or password". No local row is created. |
5xx, timeout, connect error, blocked URL, missing secret | Fail-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:
| Bucket | Limit |
|---|---|
| Per email, per minute | 5 |
| Per email, per hour | 20 |
| Per IP, per minute | 20 |
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:
| Field | Notes |
|---|---|
email | Required. The identity key within the tenant. |
password | As 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, picture | Straight copy. |
emailVerified | Defaults to false. Set it if your source says verified — otherwise you will re-verify everyone. |
externalId | Your 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. |
federationProvider | The 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 migrated | Where it lives instead |
|---|---|
| App and user metadata | Separate calls to PATCH /api/v1/users/{userId}/metadata/app and .../metadata/user after the users exist. |
| Roles and permissions | The RBAC surface — see Authorize with roles, permissions, and relationships. Assign after import. |
| Organization membership | The import always creates users at tenant level with no organization. See B2B Organizations. |
| MFA enrolments | Nothing 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. |
| Groups | Provision through SCIM, or model as organizations. |
| Sessions | Never. Everyone signs in fresh. |
| Password history, lockout counters, breach state | Never. 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_CLOSEDdefault 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:
- 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
CREATEDrow 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. - Reconcile the counts.
imported + skipped + failedmust equalrowsTotal. A largeskippedon a first run means the users were already there — check you are not importing into a tenant that has been imported already. - Read the errors array. If
errorsTruncatedistrue, the sample is not the whole story; fix the class of error it reveals and re-run — the re-run is safe. - Check
emailVerifiedlanded. It defaults tofalse. Getting this wrong sends your entire user base through email verification on first login. - For trickle, watch the first real logins. A misconfigured
clientIdor 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: DISABLEDand Thoryn stops contacting the source immediately. No data is deleted; flip back toACTIVEto 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):
| Method | Path | Scope | Description |
|---|---|---|---|
POST | /api/v1/users/import | tenant:users.import | Import up to 500 users synchronously. Returns per-row results. |
POST | /api/v1/users/import/jobs | tenant:users.import | Submit an async import job (up to max-rows, default 10,000). Returns 202 + job id. |
GET | /api/v1/users/import/jobs | tenant:users.import | Cursor-paginated list of the tenant's jobs, newest first. |
GET | /api/v1/users/import/jobs/{id} | tenant:users.import | Poll job status, live counters, and per-row errors. |
POST | /api/v1/users/import/jobs/{id}/cancel | tenant:users.import | Cancel 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:
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /api/v1/migration-sources | tenant:migration.read | The tenant's configured source. 404 when none. |
PUT | /api/v1/migration-sources | tenant:migration.write | Create or replace the source. Secret is write-only. |
DELETE | /api/v1/migration-sources | tenant:migration.write | Remove the source and its Vault secret. Idempotent. |
POST | /api/v1/migration-sources/import | tenant:migration.write | Start 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/latest | tenant:migration.read | The 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.read | Poll 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.
errorCode | Status | Cause |
|---|---|---|
empty_batch | 400 | users was empty. |
batch_too_large | 400 | Over the row cap. Nothing was processed. |
import_unavailable | 503 | The job payload could not be encrypted for storage. Nothing was persisted. |
job_not_found | 404 | Unknown job id, or one belonging to another tenant. |
job_not_cancellable | 409 | The job is already completed, failed, or already cancelled. |
invalid_cursor | 400 | The ?cursor= value on a job-list request is malformed, or was minted for a different tenant. |
migration_source_not_found | 404 | No source configured for this tenant. |
export_unsupported | 400 | The configured source has no bulk profile-export API (eager import). |
import_already_running | 409 | An eager profile import is already in progress for this tenant. |
facade_not_configured | 503 | The migration-source facade is not configured on this deployment. |
upstream_identity_error | 502 | identity-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 envelopeThe 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
503beats 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
pbkdf2by requiring a parameter declaration that matches. Storing a credential that can never authenticate anyone is a silent lockout, so the row fails loudly instead. Theargon2structural 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
clientSecretwrite-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.importis 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 oftenant:users.write. (The operator-planeSCOPE_admintwin 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.
pbkdf2verifies only at Spring Security v5.8 default parameters, and you must declare them.scryptadditionally 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.bcryptandargon2are unaffected at any parameters.argon2structural 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 oneargon2case where the "round-trip one login first" advice still earns its keep.auth0andcognitoare the trickle connectors, and both now support eager bulk profile import — Auth0 via the Management API / bulk export job, Cognito via the adminListUsersAPI (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.
Related
- Provision users and groups with SCIM 2.0 — keep the directory in sync from your IdP instead of importing once.
- Add a federation member — the coexistence path, and the destination for users whose credential you never move.
- Authorize with roles, permissions, and relationships — what to do after the users land, since roles do not migrate.
- B2B Organizations — the organization model imported users are not placed into.
- Integrate an application — pointing your apps at Thoryn once the users are there.