Auth guides
11 · Migrating users

Migrating users from another provider.

Bring your directory over in one call — emails, usernames, roles, tenants, metadata and the password hashes you already hold. Nobody resets a password unless their hash is one Auth cannot verify at the same cost as its own.


1

What an import row looks like

POST /v1/apps/:appId/end-users/import takes NDJSON — one JSON object per line, up to 20 MiB or 50,000 rows per call. Auth-admin lane: a PAK or your console session with auth.user.import on the app.

users.ndjson
{"email":"ada@example.com","username":"ada","display_name":"Ada Lovelace","role":"admin","tenant_slugs":["acme"],"external_id":"auth0|5f3c...","email_verified":true,"metadata":{"plan":"pro"},"password_hash":"$2b$10$R6DYoIs4PvhuM/Id4Xvl1u0MaPH4Bxt.uS0cJGv1CP0y82VnEKnIe","password_hash_algorithm":"bcrypt"}
{"email":"grace@example.com","password_hash":"$argon2id$v=19$m=65536,t=3,p=2$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG","password_hash_algorithm":"argon2id"}
{"email":"linus@example.com","email_verified":true}

Only email is required. username is derived from the local part when absent, role falls back to the app's signup default, and a row without password_hash creates an account that signs in once its owner completes a password reset (or a passkey / federated sign-in, if the app offers them). Field names are accepted in snake_case or camelCase.

Re-runs are idempotent. Rows match on external_id first, then on the primary email, and update rather than duplicate. Three things are never overwritten on an update: an existing password credential, an address that is already verified, and an existing member's role unless the row names one. So re-running yesterday's file does not roll anyone's password back or demote your admins.


2

Password hashes: argon2id and bcrypt

A hash without a password_hash_algorithm is refused — Auth never guesses. Two algorithms are accepted.

  • argon2id — any parameters; the PHC string carries them. Verified exactly like a credential Auth produced itself.
  • bcrypt — $2a$, $2b$ or $2y$ (the PHP flavour), 60 characters, cost 4 to 10. This covers Auth0, PHP's password_hash, Spring Security, node bcrypt and Laravel 10 at their defaults. The hash is stored as you sent it and verified the first time the user signs in; that same request replaces it with an argon2id hash of the password they just typed. The user sees an ordinary signin. The upgrade is written to the app's audit log as auth.password.upgraded.

Every bcrypt cost is accepted; here is the one trade. Auth answers every signin attempt in the same time whether or not the identifier exists, so the signin form cannot be used to enumerate your users by timing. That floor is the cost of an argon2id verify. A bcrypt verify at cost 10 or below (Auth0, Spring, node bcrypt, PHP defaults) is cheaper than the floor, so Auth pads it up to the floor and an imported account is indistinguishable from any other. A bcrypt verify at cost 11 or 12 (Devise and Rails has_secure_password, Laravel 11+, Django's bcrypt hasher) is slower than the floor, and time already spent cannot be padded away — so until such an account signs in once and is re-hashed, it answers roughly a second later than an unknown identifier. We accept those rows anyway: the difference is small, applies only to not-yet-migrated accounts, and disappears on first signin. If that trade is not acceptable for a particular user set, import those rows without password_hash and send them through password reset.

Shape problems are caught at import too: a hash tagged argon2id that does not start $argon2id$, or one tagged bcrypt that is not a 60-character modular-crypt string, is reported in the job's error list rather than discovered at the user's first signin.


3

Queue the import and read the report

The call returns 202 with a job id. Send an Idempotency-Key so a retried upload does not queue a second job over the same file.

POST /v1/apps/:appId/end-users/import
curl https://api.auth.productcraft.co/v1/apps/<app_id>/end-users/import \
  -H 'authorization: Bearer pcft_live_...' \
  -H 'content-type: application/x-ndjson' \
  -H 'idempotency-key: migration-2026-09-24-batch-1' \
  --data-binary @users.ndjson

# 202
{ "id": "<job_id>", "status": "queued", "total_rows": 3, "processed_rows": 0, ... }
GET /v1/apps/:appId/end-users/import/:jobId
{
  "id": "<job_id>",
  "status": "succeeded",
  "total_rows": 3,
  "processed_rows": 3,
  "created_count": 2,
  "updated_count": 0,
  "failed_count": 1,
  "errors": [
    { "line": 2, "error": "`password_hash` is tagged bcrypt but is not a 60-character `$2a$`/`$2b$`/`$2y$` modular-crypt hash" }
  ],
  "started_at": "2026-09-24T09:12:03Z",
  "finished_at": "2026-09-24T09:12:04Z"
}

succeeded means the job ran to completion, not that every row landed — read failed_count and errors. Each error carries the 1-based line number of the uploaded file and a message that is actionable on its own. The first 500 errors are recorded; the counts stay exact past that. Split a larger migration into several calls; that is also how you get a report you can read.

The import itself is audited as users.imported with the row count. Password hashes are never echoed back on the job.


4

Watch the migration complete

Every first signin against a bcrypt credential writes an audit row when it upgrades the hash.

GET /v1/apps/:appId/audit-logs?action=auth.password.upgraded
{
  "data": [
    {
      "id": "0e2b1c6a-…",
      "app_id": "<app_id>",
      "action": "auth.password.upgraded",
      "resource_type": "account",
      "resource_id": "<account_id>",
      "actor_id": "<account_id>",
      "metadata": { "from": "bcrypt", "to": "argon2id" },
      "created_at": "2026-09-25T07:41:19Z"
    }
  ],
  "pagination": { "next_cursor": null, "has_more": false }
}

Once every imported user has signed in once, no bcrypt hash remains in your app. Users who never come back keep a bcrypt credential that still verifies; there is nothing you need to do about them.


5

Export the directory

The other direction: GET /v1/apps/:appId/end-users/export streams the directory as NDJSON or CSV, with the list's filters.

GET /v1/apps/:appId/end-users/export
curl 'https://api.auth.productcraft.co/v1/apps/<app_id>/end-users/export?format=ndjson&status=active' \
  -H 'authorization: Bearer pcft_live_...' > users.ndjson

Gated on auth.user.export, which is escalation class: it hands over every address in the directory in one call. Streamed oldest-first so a re-run is stable; a request matching more than 1,000,000 users is refused with 413 before any byte is written — narrow it with status, search or provider. Password hashes are not exported.