Migrate users in and out
Import accounts and supported password hashes, reconcile IDs, and plan a cutover.
Move accounts into Lepsto Users, reconcile their new IDs, and switch sign-in after a rehearsal. Supported password hashes can move with the accounts: the first successful sign-in verifies the imported hash and replaces it with an argon2id hash.
You can also export users. Exports include account data, but never password hashes.
Before you start
- Create a non-production product for a migration rehearsal.
- Prepare a repeatable export from the old system; you will need a second run for the cutover delta.
- Read the actual password-hashing code to identify the algorithm and parameters.
- Add a place in your own data model to store the new Lepsto user ID.
What moves
Moves | Does not move |
|---|---|
The account and its profile | Sessions — everyone signs in once more |
Email addresses, with their verified state | Second factors |
Profile fields and the metadata bags | Password reset links already in flight |
Password hashes in the four supported formats, on the way in | Password hashes on the way out — no export carries one |
Supported metadata bags | Linked Google/GitHub identities, source IDs, and original creation timestamps |
Lepsto assigns each imported user a stable opaque ID used in tokens and webhooks. Keep a mapping from old IDs to new IDs so existing application records still resolve. Do not use email as the mapping key: addresses can change. See Lepsto Users.
Prepare the import records
- Build a
usersarray with one record per account. Each needs at least one email identifier. Replace the example address and name below. Setverified: trueonly when the source system confirmed that address:
{
"users": [
{
"identifiers": [
{ "type": "email", "value": "ada@example.com", "verified": true }
],
"name": "Ada Example"
}
]
}Identifiers accept type, value, optional isPrimary, and optional verified. The first identifier of each type is primary by default. Duplicate identifiers within one row fail that row. Optional account fields are name, imageUrl, publicMetadata, privateMetadata, unsafeMetadata, waitlisted, requiresReset, and the password object described below. The two flags default to false.
The import does not accept flat email, emailVerified, or passwordHash fields. It also does not import externalId, original creation timestamps, or social identities. Unknown keys are ignored, so do not rely on them to preserve data. Keep the old-to-new ID mapping in your own application; you can retain a legacy ID inside a supported metadata bag when useful.
Imports are idempotent by canonical identifier. A repeated canonical email later in the batch, or one already verified in the product, is skipped with duplicate_identifier: <value>. Existing unverified copies do not block import. Skipped rows are not updated. The response includes counts and a result for each row; a malformed row does not fail the whole batch.
- For the app workflow, open Users → Import. Paste data or choose a CSV, TSV, or JSON-array file, map its columns, review the parsed rows, and commit the import. The wizard batches requests and finishes with a per-row report. An agent can also run the import.
The wizard maps the address, display name, password hash, and hash algorithm. Use the API for supported fields the screen cannot map, including per-identifier verification and metadata. Source IDs and social identities are not supported import fields on either path.
For an agent, users_users_import requires product-scoped admin access and accepts 1–500 rows per call in users. Batch larger files and inspect each response before continuing.
- Read the result, not just the parser preview. Each row reports
created,skipped, orfailed, with its zero-basedrow,reason, and auserIdfor created rows. The preview catches unreadable input; directory collisions are resolved when the import runs. Correct malformed addresses, duplicate input, or invalid hash formats, then retry. Previously imported verified addresses are skipped.
Where to import from
Clerk: export IDs, address verification state, profile data, and metadata through its backend API. Retain provider details separately for your migration records; Users cannot import those links. Request password hashes separately from Clerk and confirm their algorithm; allow time for that request while preparing the rest of the migration. Preserve the public, private, and unsafe metadata boundaries.
Supabase Auth: read the authentication tables in your database:
- In
auth.users, retainidin your own reconciliation map, mapemailto an email identifier’svalue, and derive itsverifiedflag fromemail_confirmed_at. A timestamp is verification evidence;nullis not.encrypted_passwordcontains the bcrypt hash, commonly$2a$. Skip deleted rows and decide how to preserve bans represented bybanned_until. - In
auth.identities, inspect Google and GitHub records to identify users who need provider relinking. These identities cannot be imported. Anemailidentity represents password login. - Treat
raw_user_meta_dataas untrusted browser-written data and map it tounsafeMetadata. Reviewraw_app_meta_databefore mapping it toprivateMetadata, especially any roles your new application will enforce.
A provider-only user may have no encrypted_password. Import their profile without a password and plan email-based access or provider relinking. Do not invent a password hash.
Your own database: check these details in the implementation before exporting:
- Identify the hash algorithm and exact parameters. Bcrypt strings can be retained directly; separately stored PBKDF2 salt and iteration fields must be assembled into the supported format. Peppered digests cannot be verified without their original secret and are not supported by this import.
- Find reliable address-verification evidence. If the system has none, import the addresses as unverified.
- Reconcile multiple records for the same person before preparing the migration. Social-login rows need a separate relinking plan because the import does not carry their identities.
Keep the export and mapping scripts. Run them against a copy of your data for rehearsal, then reuse them for the production delta.
Supply the password hashes
Submit hashes, never plaintext passwords. Use password.phcHash for an argon2id PHC string, or password.foreignHash with foreignHashAlgo for a supported foreign hash. Specify its family explicitly. Supply exactly one hash form; giving both forms, or a foreign hash without its algorithm, fails the row:
Family | What the string looks like |
|---|---|
bcrypt | $2a$, $2b$ or $2y$, then the cost and the salted digest |
argon2 | $argon2id$v=19$m=…,t=…,p=…$…$… (also argon2i, argon2d) |
PBKDF2 | $pbkdf2-sha256$ or $pbkdf2-sha512$, then the iteration count, salt and digest |
scrypt | $scrypt$ln=…,r=…,p=…$…$… |
A foreign hash is stored until sign-in; import does not verify a password against it. Set requiresReset: true when you do not trust the source hash.
When the old schema separates the digest, salt, and cost parameters, assemble the supported string using the original values. A different PBKDF2 iteration count, for example, will never verify the original password.
At the first successful password sign-in:
- Users verifies the supplied password with the imported algorithm and parameters.
- If it matches, the normal sign-in flow continues.
- Users replaces the foreign digest with argon2id and discards the old hash.
This upgrade happens per user as they return. Later sign-ins use the new hash; no background conversion job is needed.
An imported hash cannot be checked against a plaintext password policy. The configured policy applies when the user next changes the password. After a successful sign-in, the breach check can flag the password and ask for a change without blocking that sign-in.
Handle hashes that cannot come over
Plan a password reset for unsupported formats, custom MD5 or SHA-1 schemes, peppered hashes, or providers that do not export hashes.
Import those accounts without password data. Preserve their addresses and metadata, and plan how they will set a password:
- Password attempts cannot verify without a hash. The user is prompted to set a new password.
- Recovery through a verified address lets them set it and clears the reset requirement.
- You can separately send an invitation that lets them establish their first password. Do this deliberately; import itself sends no email.
A verified address can use email codes when enabled. Provider-only users must establish access and link their Google or GitHub account again; its old link is not imported.
Set emailVerified honestly
Imported identifiers default to verified. Set verified: false on each identifier the old system did not confirm. This differs from creating a user individually.
An address imported with valid verification evidence remains verified and does not need a new confirmation.
When evidence is missing, import the address as unverified. Verification is stored per address, so this does not change the status of other addresses on the account.
Carry the linked social accounts
Google and GitHub identity links cannot currently be imported. An identities field is ignored, so copying a provider subject ID into an import record does not preserve the link.
Plan how provider-only users will regain access. They can sign in through a verified email method and link the provider from settings after fresh authentication. Normal automatic linking also applies: the provider must return a verified address matching exactly one verified user address. Do not promise that a provider sign-in will recover an unverified import; it can result in a separate account.
Register production Google and GitHub applications and configure their credentials before cutover. Shared development credentials are for testing.
Plan for sessions
Sessions cannot be imported. Users creates sessions through completed authentication flows and issues new credentials.
Choose whether to end old sessions at cutover or let them expire. Ending them gives everyone one new sign-in and makes the active authentication system clear. Allowing overlap avoids an immediate sign-in for some users but requires your application to support both systems temporarily.
Run a phased cutover
Use a phased cutover to test the migration before changing production sign-in:
- Rehearse in a non-production product. Import a copy and test representative accounts: a supported bcrypt hash, an unsupported hash requiring recovery, and a provider-only account that needs relinking. Confirm each expected access and linking path.
- Import production data while the old system serves users. Import sends no email and does not switch the application’s sign-in.
- Reconcile IDs and counts. Read users through the management API, compare totals and samples, and save the old-to-new ID mapping.
- Import new accounts from the delta. Re-export changes since the first snapshot. Existing verified identifiers are skipped, not updated: changed passwords on those records need to be set again in Users. Keep this overlap short.
- Switch sign-in. Deploy the current keys, allow the production origins and callback, and route the application to Users. Send planned invitations for accounts without usable passwords.
- Freeze and retire the old system. Block credential writes immediately. Monitor sign-in outcomes for several days and reconcile remaining deltas before retiring it.
Subscribe to supported outbound events such as user.created before switching sign-in so subsequent account creation reaches your application. session.created is not a supported outbound webhook subscription. Use the import report to reconcile imported rows. See Receive user events.
Export again
Use users_users_export with product-scoped admin access to page through full records: IDs, addresses and verification state, profiles, and metadata. It accepts directory filters and returns nextCursor for pagination. Every call is audited.
In the app, choose Export on Users → Users. It downloads a CSV of the entire filtered list, walks all result pages, and displays progress. This is a directory snapshot; use the export tool when you need full records.
Next steps
- Authentication configuration for password policy and production OAuth credentials.
- User management for the import wizard and reports.
- User events for keeping application data synchronized.
- Waitlists for importing a pre-launch list.