This is the afternoon-sized path: export one file, dry-run it, import it, and prove one original password through Signet's normal /sign-in/email wire.
1. Export from Clerk
In the Clerk Dashboard, open the instance's Settings, find User Exports, choose Export all users, and download the CSV when it is ready. Clerk documents that this Dashboard export includes hashed passwords; it never exports plaintext passwords.
Sources (retrieved 2026-07-22):
- Clerk's “Migrating your data” guide
- Better Auth: Migrating from Clerk
Clerk's page does not publish a versioned, exhaustive header schema. The exact columns below are grounded in the Better Auth guide's parser for the downloaded Clerk Dashboard CSV. Signet reports every additional column as SKIPPED, so a Clerk format change cannot disappear silently.
2. Dry-run the whole file
Point the CLI at the same signet.toml and PostgreSQL database the server uses:
signet import --config /etc/signet/signet.toml \
--format clerk-csv --dry-run clerk-users.csvThe dry run is also the import preflight: it parses and validates every row, checks the live database for existing email/external-id conflicts, and performs no writes. Before the per-row verdicts it reports the number of source rows, distinct normalized emails, emails appearing more than once, and duplicate emails carrying differing hashes. Every duplicate group names its source row numbers without printing the email or either hash. Only rows with a valid normalized email and supported hash contribute to the email counts; invalid rows still appear in total and receive their own FAILED verdict.
preflight: rows=2 distinct_emails=2 emails_appearing_more_than_once=0 differing_hash_emails=0The remaining receipt contains one line for every row plus a final summary:
row 2: WOULD_IMPORT — validated user and credential account; fix: remove --dry-run to write this row
row 3: SKIPPED — cause: a user with this normalized email already exists; fix: no action is needed for an idempotent re-run; use a unique email for a different user
summary: total=2 imported=0 would_import=1 skipped=1 failed=0 skipped_columns=5 dry_run=trueFix every FAILED receipt before continuing. Reports never print a password hash.
3. Import and prove a sign-in
signet import --config /etc/signet/signet.toml \
--format clerk-csv clerk-users.csvEach accepted row is written transactionally as:
- one
userrow, preserving Clerkidas Signetuser.id; and - one
accountrow withproviderId = 'credential',accountId = user.id, and the existing digest inaccount.password.
The normalized email is the idempotency key. Import normalization trims the value and applies the same full-Unicode lowercase operation as email sign-in (.to_lowercase()), so addresses such as MÜLLER@example.com match their native müller@example.com user. Re-running the same unchanged file reports the rows as skipped and does not duplicate either table. If an existing email's stored credential is absent or differs from the incoming hash, the row fails: those records may represent different people, so Signet requires the operator to reconcile the source identities or assign unique login emails instead of choosing or merging credentials automatically. A conflicting Clerk id attached to another email likewise fails instead of merging two identities.
Now sign in one known user through your application's existing better-auth client, or directly:
curl -i https://auth.example.com/api/auth/sign-in/email \
-H 'content-type: application/json' \
--data '{"email":"ada@example.com","password":"the-original-password"}'Clerk Dashboard password exports are normally bcrypt. Signet also verifies imported Argon2, PBKDF2-PHC, scrypt-PHC, and better-auth-native scrypt strings. A successful email or username login best-effort replaces a foreign hash with Signet's better-auth-native scrypt hash; native registrations continue to use that same native format from the start. If hashing or writing the upgrade fails, Signet logs a warning with the cause and completes the already-successful login with the foreign hash unchanged. No HTTP response shape changes during the upgrade.
Idempotency compares the exact stored hash representation; it does not assume two independently salted or differently encoded hashes belong to the same person. Consequently, after a successful login has upgraded an imported foreign hash to native scrypt, re-running the old export refuses that row for operator review rather than treating the changed representation as a safe merge.
Clerk columns
Signet consumes these Clerk Dashboard CSV columns:
| Clerk column | Signet destination | Rule |
|---|---|---|
id | user.id | Required; preserved as the external identity key. |
primary_email_address | user.email | Required; trimmed and full-Unicode lowercased exactly like email sign-in. |
first_name, last_name | user.name | Joined with one space; email local-part is the empty-name fallback. |
verified_email_addresses | user.emailVerified = true | The primary email must appear in this pipe-delimited list. |
unverified_email_addresses | user.emailVerified = false | The primary email must appear here when it is not verified. |
email_verified | user.emailVerified | Optional explicit true/false alias for CSV-to-JSON transforms. |
password_digest | account.password | Required, self-describing supported hash. |
password_hasher | validation only | Must agree with the digest; Clerk exports normally say bcrypt. |
The primary address appearing in both verification lists, or neither list, is a failed row. Signet does not silently promote an unknown state to verified or demote it to unverified.
The documented Clerk export also carries fields Signet deliberately does not consume in this bounded credential migration: username, primary_phone_number, verified_phone_numbers, unverified_phone_numbers, and totp_secret. Each present field gets a column-level skip receipt. Review those receipts and migrate the corresponding plugin data separately. In particular, never copy a TOTP secret without reviewing the target plugin's encryption and recovery-code contract.
Passwordless/social-only rows have no password_digest; they fail with an actionable receipt instead of creating an account that appears password-capable but cannot sign in. Active Clerk sessions and provider-bound OAuth grants are not portable through this credential CSV.
Clerk JSON
--format clerk-json accepts a JSON array using the same keys as the Dashboard CSV. This is useful after a lossless CSV-to-JSON conversion; email-list fields may be pipe-delimited strings or arrays of strings. It is not the nested Clerk Backend API User response contract.
signet import --format clerk-json --dry-run clerk-users.jsonGeneric CSV contract
For another provider, use --format csv. The header contract is:
| Column | Required | Meaning |
|---|---|---|
email | yes | Login email; trimmed, full-Unicode lowercased, and used for idempotency. |
password_hash | yes | bcrypt, Argon2 PHC, PBKDF2 PHC, scrypt PHC, or better-auth-native scrypt. |
email_verified | yes | Exactly true or false. |
external_id | no | Preserved as user.id; otherwise Signet generates the id. |
name | no | Falls back to the email local-part. |
image | no | User image URL. |
organization_slug | no | Slug of an organization that already exists in Signet. A blank or absent column preserves the user-only import behavior and creates no membership. An unknown slug fails the row; import never creates organizations. |
organization_role | no | Membership role: exactly owner, admin, or member. Defaults to member when organization_slug is present, and is refused when no organization is named. |
created_at, updated_at | no | RFC3339 timestamps; import time is the fallback. |
As with Clerk input, every unknown column receives a skip receipt and every failed/skipped row names its row number, cause, and fix.
organization_slug is optional so every existing generic CSV remains a user-and-credential import with no membership side effects. The contract uses a slug rather than Signet's deployment-local organization ID because a migration file needs an operator-readable identifier that survives the move between systems. Organization authorization labels have no governing IETF, OpenID Connect, or W3C vocabulary; organization_role therefore uses the Signet organization API's existing accepted roles instead of inventing a second set.
Repeated normalized emails are handled together with membership import. An identical stored credential representation proves a copied source row because the source password hasher uses a fresh random salt for every hash; Signet attaches any still-missing requested membership. A different representation may name another person, so the row fails and no membership is attached. For a live import, each such refusal also writes a machine-readable file beside the source, named <source>.refused-collisions.json (or the next unused numbered variant). Each entry names the row, normalized email, organization, and both operator choices; it never contains password hashes. --dry-run remains non-mutating and reports the same collisions on stdout without writing this file.