Skip to content

Product documentation

Migrate from Auth0 or Amazon Cognito

A step-by-step runbook for moving a user base off Auth0 or Amazon Cognito onto Thoryn using the turnkey vendor-export import endpoints — the export commands, the field mapping, an honest account of which credentials carry over, and cutover and rollback.

Migrate from Auth0 or Amazon Cognito

This is a runbook for moving an existing user base off Auth0 or Amazon Cognito onto Thoryn. Both vendors export in their own JSON shape, so Thoryn ships a turnkey import endpoint per vendor: you upload the vendor's own export file unchanged, and Thoryn maps it into a user directory for you. No CSV wrangling, no reshaping into a platform format.

The one decision that shapes the whole migration is the same for every provider: will your source hand you the password hashes? It decides whether your users keep their passwords or all pass through a reset.

  • Auth0 stores bcrypt hashes and can release them — but not through the self-service export. Getting them out is a separate, manual step (see below). When you have them, users keep their passwords and never see a reset.
  • Amazon Cognito never exports password hashes — there is no API that returns them. Every migrated Cognito user is created password-less and re-establishes a credential through password reset or federation. This is inherent to Cognito, not a Thoryn limitation.

This runbook covers the vendor-specific endpoints only. For the full migration picture — the three strategies (bulk import, trickle migration that verifies at first login, and federate-and-coexist), the deep password-hash parameter rules, and what carries across versus what does not — read Migrate your users to Thoryn first. Everything here builds on the bulk-import strategy described there.

Auth0 vs Cognito at a glance

Auth0Amazon Cognito
Turnkey endpointPOST /api/v1/users/import/auth0POST /api/v1/users/import/cognito
Export shapeFlat JSON array of user objectsJSON array of Username + Name/Value Attributes
Password hashes in the exportbcrypt — but only via an Auth0 support requestNone, ever
User experience after migrationKeeps their password, no resetPassword reset or federation on first sign-in
Federated usersCarried for correlation (identities[])Carried for correlation (identities / EXTERNAL_PROVIDER)

Both endpoints share the same import pipeline, row caps, partial-failure contract, and job model as the native /api/v1/users/import surface — the vendor endpoint only adds the mapping step in front. You poll, list, and cancel jobs through the one shared /api/v1/users/import/jobs surface; there is no parallel Auth0 or Cognito job system.

Before you start

You need:

  • A tenant admin token carrying the tenant:users.import scope. This is a scope of its own, deliberately separate from tenant:users.write, because bulk-writing a whole directory (with credentials that grant sign-in) is a far larger blast radius than single-user CRUD. Grant it to a one-off migration client and revoke it when you are done. It is granted on the customer-plane clients by hub migration V91; if your token does not carry it, ask your Thoryn contact to enable it.
  • A target tenant your production traffic is not pointed at yet. Import there, verify there, then cut traffic over. The import only ever creates — an existing email is skipped, never overwritten — so this is the safe sequencing, not a mandatory one.
  • A way to drive the import. Today the import is driven through the API/CLI — the vendor endpoints below, run with curl and jq (or a language client) — which is what the rest of this runbook shows, and the supported path for CI, scripted cut-overs, and runs you keep under version control. Use the server-side dry-run (Step 3 below) as your validate-before-commit step: it returns the per-row prediction — what would be created, skipped, or fail — before a single row is written. A guided console migration wizard (upload the vendor export → Validate → Commit, with that same dry-run wired in as its Validate step) is planned but not yet available; it is built in the console (a separate repository), and until it ships the API/CLI path above is how you migrate.

Throughout, $TOKEN is your tenant admin bearer token and api.thoryn.org is the public API host; on a self-managed deployment substitute your own gateway host.

Part 1 — Migrate from Auth0

Step 1: Export your Auth0 users

Auth0 splits what you need across two exports, and this is the part most Auth0 migrations underestimate.

Profiles (self-service). Get the user records — emails, names, verification flags, user_id, linked identities — with either:

  • The User Import / Export dashboard extension (Extensions → "User Import / Export" → Export), which produces a gzipped JSON or CSV; or

  • The Management API bulk-export job:

    curl -X POST https://YOUR_TENANT.auth0.com/api/v2/jobs/users-exports \
      -H "Authorization: Bearer $AUTH0_MGMT_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "connection_id": "con_xxxxxxxx",
        "format": "json",
        "fields": [
          { "name": "email" }, { "name": "email_verified" },
          { "name": "given_name" }, { "name": "family_name" },
          { "name": "name" }, { "name": "nickname" }, { "name": "picture" },
          { "name": "user_id" }, { "name": "identities" }, { "name": "blocked" }
        ]
      }'

    The job returns an id; poll GET /api/v2/jobs/{job_id} for the signed download URL.

    Both routes give you a JSON array of user objects — but neither includes the password hash. Auth0 does not expose stored hashes through the Management API or the extension, by design.

Password hashes (support request — the manual step). To move passwords without a reset you need the bcrypt custom_password_hash records, and the only way to obtain them is to open a support request with Auth0 asking for a bulk password-hash export for the connection you are migrating. Auth0 delivers the hashes out of band. This is a prerequisite you cannot self-serve, so plan for its lead time.

If you do not get the hashes, an Auth0 migration is a profiles-only import: every user is created password-less and must reset, or you run trickle migration so passwords verify at first login without ever moving the hash. Decide this before you import — it changes the user experience for everyone.

Assemble the import file. Thoryn's /auth0 endpoint accepts the standard Auth0 export shape directly: a JSON array of user objects. Where you have the hashes, add each user's custom_password_hash object (or a raw passwordHash string) to their record. Unknown Auth0 fields are ignored, so you can upload the export largely as-is:

[
  {
    "email": "ada@example.com",
    "email_verified": true,
    "given_name": "Ada",
    "family_name": "Lovelace",
    "user_id": "auth0|5f7c8ec7c33c6c004bbafe82",
    "custom_password_hash": {
      "algorithm": "bcrypt",
      "hash": "$2b$10$N9qo8uLOickgx2ZMRZoMy..."
    }
  },
  {
    "email": "grace@example.com",
    "name": "Grace Hopper",
    "identities": [ { "provider": "google-oauth2", "connection": "google", "isSocial": true } ]
  }
]

Step 2: Know what carries across

The mapper reads only the subset of the Auth0 record the platform's user directory can hold. Everything else (app_metadata, user_metadata, logins_count, last_login, multifactor, …) is ignored, not an error.

Auth0 fieldThoryn user fieldNotes
emailemailRequired — the tenant-unique key. A record with no email is an ERROR row.
email_verifiedemailVerifiedDefaults to false when absent.
given_name / family_namegiven / family nameUsed directly when either is present.
name → else nickname → else email local-partgiven / family nameWhen given_name / family_name are absent, a single name is split on the last space; a lone nickname or the email local-part is the fallback.
user_idexternalIdRetained for correlation back to the Auth0 record.
picturepictureStraight copy.
blocked: truesuspended (enabled = false)The user is created but cannot authenticate until an admin clears it.
custom_password_hash / passwordHashpassword (pre-hashed)See credential carry-over.
primary non-auth0 identities[].provider / connectionfederationProviderFor a password-less federated user, the provider name is retained for correlation (see below).

Federated (social / enterprise) users. A user whose primary Auth0 identity is not auth0 — a Google, SAML, or OIDC connection — has no local password to bring, so the provider name is carried on federationProvider for correlation. For a social login that is the provider (google-oauth2, …); for an enterprise connection whose provider is a generic protocol marker (samlp, oidc, waad, …) the specific connection name is recorded instead, because that is what a Thoryn federation member corresponds to. On both the synchronous /auth0 endpoint (and its ?dryRun=true pre-flight) and the asynchronous /auth0/jobs submission, that name is resolved against your tenant's configured federation members: a match canonicalises the row to the member's provider type, and a provider that matches no configured member is flagged (unresolved_federation_provider) instead of imported with a dangling reference. On the sync path a rejection is one of the per-row results; on the async path the enqueued job only ever receives resolved rows and the rejections ride back on the 202 submission response's rejected array — so a job never carries an unresolvable provider. Attach the corresponding federation member before importing its users (and dry-run first to surface any unresolved providers). A tenant with no federation members configured yet still carries the name verbatim, nothing to resolve against — see Known limitations.

Step 3: Dry-run the whole export first

Do not import 40,000 users blind. Add ?dryRun=true to the same endpoint to validate the entire export without writing a single row. Every row runs the exact same validation a real import runs — hash usability, missing email, duplicate email, unmappable fields — and you get the per-row outcome back, but nothing is created:

curl -X POST 'https://api.thoryn.org/api/v1/users/import/auth0?dryRun=true' \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d @auth0-export.json

The response is the ordinary import shape, read as a prediction: CREATED is a row that would be created, SKIPPED_DUPLICATE one that would be skipped, and ERROR one that would fail with a stable reason code. Fix the format and hash problems it surfaces while it is still free — before any write:

{
  "imported": 39997,
  "skipped": 1,
  "failed": 2,
  "results": [
    { "email": "ada@example.com", "status": "CREATED" },
    { "email": "bad@example.com", "status": "ERROR", "reason": "malformed_password_hash" }
  ]
}

The flag works identically on the native POST /api/v1/users/import and the POST /api/v1/users/import/cognito endpoints, and it is inherently synchronous — there is no dry-run job. A dry-run is capped and gated exactly like a real import (empty_batch / batch_too_large and the tenant:users.import scope all still apply), so what passes a dry-run is what a commit will accept.

A dry-run writes nothing, so it proves the file maps and validates — but it cannot prove a user can log in (there is no password to test against a hash that is not stored yet). Once the dry-run is clean, do a real one-row import of a user whose password you know and sign in as them to exercise the whole login chain:

curl -X POST https://api.thoryn.org/api/v1/users/import/auth0 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '[ { "email": "you@example.com", "custom_password_hash": { "algorithm": "bcrypt", "hash": "$2b$10$..." } } ]'

A CREATED row means the hash passed Thoryn's usability check; a successful login proves the rest of the chain. Do both before the bulk run — together they catch the two failure modes (a malformed export, and a hash that stores but will not verify) while it is still cheap to fix.

Step 4: Import the full export

Up to 500 users — synchronous. Post the whole export array and get the per-row outcome back immediately:

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

More than 500 users — asynchronous job. Same body, the /jobs variant, up to 10,000 users:

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

You get 202 Accepted with a Location header and the job body. Poll it on the shared jobs surface — note the path has no auth0 segment, because it is the one shared job model:

curl https://api.thoryn.org/api/v1/users/import/jobs/JOB_ID \
  -H "Authorization: Bearer $TOKEN" | jq
{
  "id": "9f1c...",
  "status": "in_progress",
  "rowsTotal": 8400,
  "rowsProcessed": 3200,
  "imported": 3105,
  "skipped": 92,
  "failed": 3,
  "errors": [ { "rowIndex": 118, "email": "bad@example.com", "reason": "malformed_password_hash" } ],
  "errorsTruncated": false
}

Listing and cancelling jobs work exactly as described in Migrate your users — the same GET /api/v1/users/import/jobs and POST /api/v1/users/import/jobs/{id}/cancel.

Partial success is the contract. One bad row never aborts the batch — it becomes an ERROR result with a stable reason code and processing continues. A user whose email already exists is SKIPPED_DUPLICATE, never overwritten, so re-running the same export is safe (idempotent by skip). Only an empty array (empty_batch) or an over-cap batch (batch_too_large) rejects the whole request.

Step 5: Finish the profile

After the users land, wire up the parts that no import path carries — see what does not migrate. In short: MFA enrolments, metadata, roles, and organization membership all carry nothing and are set up after import. Plan the MFA re-enrolment prompt — it is the one thing your users will notice.

Auth0 credential carry-over

This is the part that decides whether the migration is invisible, so be precise about it.

bcrypt carries verbatim. Auth0 database connections store bcrypt, and Thoryn stores the hash exactly as given and verifies it at the migrated user's first login. $2a, $2b, and $2y all verify; the derived cost lives in the hash string, so any cost factor works. The one exception is the $2x$ variant emitted by very old PHP crypt_blowfish sources, which is genuinely unverifiable and rejected as malformed_password_hash.

Two source shapes are accepted, both from the Auth0 export:

  • custom_password_hash: { "algorithm": "bcrypt", "hash": "…" }, where hash is either a bare string or a { "value": "…", "encoding": "…" } object (the Auth0 bulk-import schema); or
  • passwordHash: "$2b$…" — a raw bcrypt string carried directly on a DB-connection export.

The mapper defaults the algorithm to bcrypt when the export omits it.

The turnkey Auth0 endpoint carries bcrypt, argon2, scrypt, and pbkdf2. Each of these carries its parameters inside the hash string, so it verifies with no separate declaration — including pbkdf2 in the passlib / PHC modular-crypt form ($pbkdf2-sha256$<rounds>$<salt>$<checksum>) that Auth0 exports for a custom pbkdf2 connection. The one shape the turnkey path still cannot carry is a bare-hex pbkdf2 (salt‖hash with no embedded parameters), which needs the KDF-parameter declaration only the native import carries; that is not how Auth0 exports pbkdf2, so an Auth0 export is unaffected. See Known limitations and the password-hash parameter rules.

A hash that would fail at login is rejected at import, as a per-row ERROR, while you can still do something about it — never stored to fail silently weeks later. The row reason codes are shared with the native import.

Part 2 — Migrate from Amazon Cognito

Step 1: Export your Cognito user pool

Cognito has no bulk-export job; you page through the pool with the AWS CLI:

aws cognito-idp list-users --user-pool-id us-east-1_XXXXXXXXX --max-results 60 \
  --output json > cognito-page-1.json

list-users returns at most 60 users per call with a PaginationToken; loop, passing --pagination-token, until it is absent, then concatenate the Users arrays into one JSON array. Each element looks like:

{
  "Username": "ada@example.com",
  "Attributes": [
    { "Name": "sub", "Value": "a1b2c3d4-..." },
    { "Name": "email", "Value": "ada@example.com" },
    { "Name": "email_verified", "Value": "true" },
    { "Name": "given_name", "Value": "Ada" },
    { "Name": "family_name", "Value": "Lovelace" }
  ],
  "Enabled": true,
  "UserStatus": "CONFIRMED"
}

Thoryn's /cognito endpoint accepts this array directly — both the list-users Attributes name and the admin-get-user UserAttributes name are understood.

There is no password step. Cognito exposes no API that returns a password hash, so there is nothing to export and nothing to add to the file. Every migrated user is password-less by necessity. Skip straight to the import.

Step 2: Know what carries across

The mapper flattens the Attributes list and reads the standard OIDC claims off it. Phone numbers, MFA state, and any custom:* attributes are read but not mapped — dropped, never made to fail a row. The account Enabled flag is carried (see below).

Cognito attribute / fieldThoryn user fieldNotes
emailemailRequired — the tenant-unique key.
email_verified (string "true")emailVerifiedCognito serialises booleans as strings; anything but "true" is false.
given_name / family_namegiven / family nameUsed directly when present.
name (when given/family absent)given / family nameBest-effort split on the last space.
sub → else UsernameexternalIdsub is Cognito's immutable id; Username may be an email, a UUID, or a Provider_id, so sub is preferred for correlation.
localelocaleStraight copy.
picturepictureStraight copy.
identities[].providerName / EXTERNAL_PROVIDER statusfederationProviderRetained for correlation; see below.
Enabled: falsesuspended (enabled = false)The user is created but cannot authenticate until an admin clears it — symmetric with an Auth0 blocked user.

A disabled Cognito user (Enabled: false) imports suspended (enabled = false), symmetric with an Auth0 blocked user — the account is created but cannot authenticate until an admin re-enables it through the users API. A missing or true Enabled imports the user active.

Federated users. A user whose UserStatus is EXTERNAL_PROVIDER, or who carries an identities attribute, is federated. federationProvider is populated from the identities attribute's providerName when present, else from the Username prefix convention (Google_…, Facebook_…, <provider>_…). As with Auth0, both the synchronous /cognito endpoint (and its ?dryRun=true pre-flight) and the asynchronous /cognito/jobs submission resolve that name against your tenant's configured federation members — canonicalising a match and flagging an unresolvable provider (unresolved_federation_provider), per-row on the sync path or in the 202 submission's rejected array on the async path — so attach the corresponding federation member before importing its users. A tenant with no members configured yet carries the name verbatim (see Known limitations).

Step 3: Import the export

Identical to the Auth0 endpoints, on the /cognito path — synchronous up to 500, asynchronous up to 10,000:

# Synchronous — up to 500 users
curl -X POST https://api.thoryn.org/api/v1/users/import/cognito \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d @cognito-export.json
 
# Asynchronous job — up to 10,000 users
curl -X POST https://api.thoryn.org/api/v1/users/import/cognito/jobs \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d @cognito-export.json

Poll, list, and cancel on the shared /api/v1/users/import/jobs surface, exactly as for Auth0. Partial success and idempotent-by-skip re-runs work the same way.

Step 4: Get every user a credential

Because no Cognito user has a password after import, you must give each one a way in:

  • Federated users (the federationProvider was set) sign in through their IdP once you attach the matching federation member. No reset needed.
  • Password users re-establish a credential through the standard forgot-password flow: the imported account exists (password-less), so a reset request for that email issues a reset link. Plan a communication campaign telling users to reset on first visit, and consider tailoring the reset email.

There is no bulk "send reset to everyone" endpoint — reset is user-initiated by design, so a mass campaign is a mail-out that points users at the forgot-password page, not an API call. If Cognito passwords matter enough to preserve, migrate off Cognito via federate-and-coexist instead and keep Cognito as the IdP until you are ready to drop the credential.

Endpoint reference

All four vendor endpoints are on product-api, reached through the api-gateway, and gated by tenant:users.import. They import into the tenant your token's tnt claim names — you cannot address another tenant, and another tenant's job id is a 404, never a 403.

MethodPathDescription
POST/api/v1/users/import/auth0Import an Auth0 export synchronously (≤ 500 users). Per-row outcome.
POST/api/v1/users/import/auth0/jobsSubmit an async Auth0-export job (≤ 10,000 users). Returns 202 + job.
POST/api/v1/users/import/cognitoImport a Cognito export synchronously (≤ 500 users). Per-row outcome.
POST/api/v1/users/import/cognito/jobsSubmit an async Cognito-export job (≤ 10,000 users). Returns 202 + job.
GET/api/v1/users/import/jobsList the tenant's import jobs (shared with native/Auth0/Cognito).
GET/api/v1/users/import/jobs/{id}Poll a job's status, counters, and per-row errors.
POST/api/v1/users/import/jobs/{id}/cancelCancel a pending / in_progress job.

Full request/response schemas are in the generated user-import API reference. The native POST /api/v1/users/import (platform-shaped body) and the whole job model are documented in Migrate your users.

Troubleshooting

malformed_request_body (400) on the whole request. The uploaded file is not valid JSON, or its root is not the array the endpoint expects (an Auth0 export or a Cognito export is a JSON array of user objects). The body carries credentials, so the parser's message is never echoed back — validate the file with jq . file.json locally.

malformed_password_hash on Auth0 rows. Most often a $2x$ hash from an old PHP crypt_blowfish source — genuinely unverifiable here, not a naming quirk. Re-hash at source into a standard bcrypt variant, or move those users via trickle migration.

missing_password_hash_parameters on Auth0 rows. A pbkdf2 hash arrived as a bare salt‖hash with no embedded parameters — the one pbkdf2 shape the turnkey path cannot carry, because it needs the KDF-parameter declaration only the native import supplies. Auth0 exports a custom pbkdf2 connection in the self-describing passlib form ($pbkdf2-sha256$…), which does import and verify, so this is unusual for an Auth0 export; re-hash those users into bcrypt at the source, or move them with trickle migration.

Everything comes back SKIPPED_DUPLICATE. The users already exist in that tenant — import never overwrites. You are probably re-importing into a tenant that was imported already, which is harmless. To change existing users, use the users API, not import.

Cognito rows import but emailVerified is false everywhere. Cognito serialises email_verified as the string "true"/"false". The mapper handles that — but if your export tooling dropped or renamed the attribute, verification will not carry and your users re-verify on first login. Confirm the attribute is present and named email_verified.

Cognito users cannot sign in after import. Expected — they are password-less by design. Send them through password reset or attach their federation member. This is not a failure.

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

batch_too_large under 10,000 rows. You posted to a synchronous endpoint (/auth0 or /cognito), which caps at 500. Use the /jobs variant.

413 payload_too_large from the gateway. The request body exceeds the gateway cap for the import path. Split the export across jobs — the import is idempotent-by-skip, so overlapping batches are safe. (See the gateway body-cap note in Migrate your users.)

For the shared job-lifecycle errors (job_not_found, job_not_cancellable, invalid_cursor, upstream_identity_error, facade_not_configured) and every row reason code, see the native import guide — the vendor endpoints share all of them.

Rollback

The safe-by-construction posture is the same as any Thoryn import:

  • Nothing is overwritten. Import only ever creates; an existing user is skipped untouched. A mistaken re-run cannot corrupt correct data.
  • Your source is untouched. These endpoints read an export file. Nothing writes back to Auth0 or Cognito — your rollback is to keep your apps pointed at the old provider until you are ready.
  • Clean up per user through the users API if a batch went to the wrong tenant. There is no "undo import" endpoint and you do not need one: import into a tenant production traffic is not pointed at yet, verify, then cut over.

Known limitations

Stated plainly so you can plan around them:

  • Very large directories are split across async jobs. Large runs commit through the async /jobs endpoints, which cap at 10,000 users per submission. A multi-hundred-thousand-user directory is therefore split across several jobs; the import is idempotent-by-skip, so overlapping or re-run batches are safe. (The planned console migration wizard, when it ships, will drive these same /jobs endpoints — see the note on the wizard under Before you start.)
  • Auth0 password hashes are not self-service. They require an Auth0 support request; the Management API and the Import/Export extension export profiles only.
  • Only a bare-hex pbkdf2 hash cannot be carried. bcrypt, argon2, scrypt, and pbkdf2 in the passlib modular-crypt form Auth0 exports ($pbkdf2-sha256$…) all carry and verify, because each embeds its own parameters. The lone exception is a bare-hex pbkdf2 (salt‖hash, no embedded parameters): it needs the KDF-parameter declaration only the native import carries and is rejected missing_password_hash_parameters on the turnkey path. Auth0 does not export pbkdf2 in that shape, so an Auth0 export is unaffected; otherwise re-hash into bcrypt at the source or use trickle migration.
  • Cognito exports no passwords. Every Cognito user resets or federates on first sign-in. This is a Cognito property, not a Thoryn one.
  • Federation-member resolution runs on every vendor import path. A federated Auth0 / Cognito user's provider name (google-oauth2, a connection name, …) is resolved against your tenant's configured federation members on the synchronous /auth0 and /cognito endpoints (and their ?dryRun=true pre-flight) and on the asynchronous /auth0/jobs and /cognito/jobs submissions: a match canonicalises the row to the member's provider type, and a provider that matches no configured member is flagged (unresolved_federation_provider) rather than imported with a dangling reference. The sync path reports a rejection as a per-row result; the async path resolves before enqueue, so the job only ever receives resolved rows and the rejections come back on the 202 submission's rejected array (when every row is rejected there is nothing to enqueue — the response is 200 with a null job). Configure the federation member before importing its users, and run a dry-run to surface unresolved providers first. One case still carries the provider verbatim: a tenant with no federation members configured yet (nothing to resolve against).
  • No MFA, metadata, roles, groups, or organization membership carry through any import path — see what does not migrate.

Security notes

  • Import payloads carry credentials and are never logged. The vendor endpoints, the mappers, and the upstream client all avoid logging request bodies; an async job's payload is encrypted at rest and wiped once the job reaches a terminal state.
  • A hash that cannot verify is rejected, not stored. Storing a credential that can never authenticate anyone is a silent lockout, so the row fails loudly at import instead.
  • tenant:users.import is a high-blast-radius grant — it can write your whole directory, including sign-in credentials. Scope it to the migration client and revoke it when the migration is done.
  • Tenant isolation holds. You can only import into your own tenant (tnt claim), and a job belonging to another tenant is indistinguishable from one that does not exist — both are 404.