Migrate 40,000 users with their bcrypt hashes and no reset emails
Blog GuideYou have a users table with 40,000 rows and a column of bcrypt hashes. You want to move auth to Rekey. The question that decides whether the migration is a quiet evening or a bad week is what happens to the passwords. Rekey takes the hashes you already have, checks them as-is at sign-in, and replaces each one with argon2id the first time its owner signs in. Nobody with a bcrypt or argon2id hash gets a reset email.
What a forced reset costs
A migration that cannot carry the hashes ends with one email to every user: set a new password before you can sign in again. Some of it lands in spam, some addresses are dead, and some people click the link a week after the token expired. Every one of those is a support ticket or a lost account, and all of them arrive in the same two days, on top of the cutover itself. The users who never come back are the ones who were already on the fence.
The hashes are the expensive part of a users table. bcrypt and argon2id are both designed so the stored string is enough to check a password and nothing else, which means a new system can verify them without ever having seen the plaintext. The old system does not need to be running. You export the column and ship it.
The plan
- Freeze password writes in the old system (password change and reset routes). An import never updates a row that already exists, so a password changed after export stays behind.
- Export email, hash, verified flag, role and any linked OAuth accounts.
- Import in batches of up to 500 with
POST /api/v1/users/import, keeping theskippedandunlinkedlists. - Point sign-in at Rekey. Leave the old table in place, read-only.
- Verify: counts, a few real sign-ins, the hash column.
- Drop the old table.
The request
The route takes a secret key with the auth:write scope. Each row needs an email; everything else is optional. Send emailVerified: true for every address your old system had confirmed. It defaults to false, and that default matters more than it looks (see below).
POST /api/v1/users/import
Authorization: Bearer rp_live_...
Idempotency-Key: import-batch-0001
Content-Type: application/json
{
"users": [
{
"email": "[email protected]",
"passwordHash": "$2b$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW",
"emailVerified": true,
"role": "user",
"metadata": { "legacyId": 48213 }
},
{
"email": "[email protected]",
"passwordHash": "$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHQ$aGFzaGhhc2hoYXNo",
"emailVerified": true
},
{
"email": "[email protected]",
"emailVerified": true,
"oauthIdentities": [
{ "provider": "google", "providerAccountId": "109876543210" }
]
}
]
}The response says what happened to each row.
{
"success": true,
"data": {
"created": [
{ "id": "cmh3...", "email": "[email protected]" },
{ "id": "cmh3...", "email": "[email protected]" },
{ "id": "cmh3...", "email": "[email protected]" }
],
"skipped": [],
"unlinked": []
}
}From Node the same call is rekey.users.import(users) in @rekey.dev/node, which returns the same created, skipped and unlinked arrays:
import { Rekey } from '@rekey.dev/node';
const rekey = new Rekey({ apiUrl: process.env.REKEY_URL!, secretKey: process.env.REKEY_SECRET! });
for (const batch of chunk(exportedUsers, 500)) {
const { created, skipped, unlinked } = await rekey.users.import(batch);
console.log(created.length, 'created', skipped.length, 'skipped', unlinked.length, 'unlinked');
}Rows are validated as a whole before any row is written. A batch with a malformed hash, an unknown role, a metadata blob over 16 KB or the same address twice is refused entirely, so you fix the file and resend rather than untangle a half-applied batch. Once past validation, rows are written one at a time: an address that already exists in the Application comes back as skipped with reason already_exists, and is never updated. That makes a resend safe, and it also means an import cannot be used to overwrite a live account's password.
The route also honours an Idempotency-Key header. A retry with the same key and the same body within 24 hours returns the stored response with Idempotency-Replayed: true and executes nothing, which is the right behaviour when a batch call times out halfway through and you cannot tell how far it got.
Which hashes are accepted
Two formats, checked by prefix. Everything else is refused with 400 PASSWORD_HASH_UNSUPPORTED before any row is written.
| Format | Accepted when |
|---|---|
bcrypt ($2a$, $2b$, $2y$) | Cost 12 or below. A hash at cost 13 or more is refused. |
argon2id ($argon2id$v=19$m=…,t=…,p=…$salt$hash) | Memory at most 262144 KiB, time cost at most 10, parallelism at most 8. |
| Anything else | Refused. Omit passwordHash for that user and let them reset, or send their OAuth identity instead. |
The ceilings exist because sign-in honours whatever parameters the stored hash asks for. Without them, one import could turn every sign-in into a memory and CPU sink. Where that leaves the common sources:
- Rails with Devise, and PHP with Laravel, store bcrypt. Laravel writes the
$2y$prefix, which is accepted as-is. Check the cost your config uses; 12 is the ceiling. - Django prefixes every hash with the hasher name and a
$. Strip that so the string starts with$argon2id$or$2b$. Django's default hasher is PBKDF2, which Rekey does not accept, so those users need a reset unless you had switched Django to its argon2 or bcrypt hasher. - Firebase Authentication stores passwords with its own modified scrypt. Those cannot be imported as hashes. Import the users with
emailVerifiedand theiroauthIdentities, and let password-only users set one through a reset.
What happens at the first sign-in
Rekey never creates a bcrypt hash itself. Its own hashes are argon2id. An imported bcrypt hash is checked with a bcrypt compare on a small worker pool, off the event loop. When the compare succeeds, the plaintext is in hand for the one moment it ever will be, so Rekey hashes it with argon2id and writes the new hash over the old one. The write is conditional on the bcrypt hash still being the stored one, so a reset or an account claim that landed in between is never undone. From then on that user is indistinguishable from one who signed up on Rekey.
The pool is two workers with a queue of 32. At cost 12 one worker does about three to five compares a second, so a single API process checks roughly six to ten imported-bcrypt sign-ins a second. When the queue is full a sign-in answers 503 PASSWORD_VERIFY_BUSY with a Retry-After header; the password was not checked, so it is never read as wrong and never counts toward lockout. Your sign-in handler should retry on that status. The load is self-limiting: every successful sign-in removes one bcrypt hash from the pool's future work, and argon2id checks run through a separate limit of four at once per process.
If you self-host, you can watch the upgrade happen. The passwordHash column on EndUser starts with $2b$ after import and with $argon2id$ after the first successful sign-in.
Why emailVerified is the field to get right
An unverified account in Rekey belongs to whoever first proves the mailbox. A magic-link sign-in, a social sign-in whose provider verified the same address, or a completed password reset marks the address verified and in the same step removes the password, every session, passkeys, a second factor and other linked providers. That is the right outcome for an address nobody confirmed. For a user your old system verified years ago it is the wrong one: their imported password would vanish the first time they used a magic link.
So map your old verified flag to emailVerified row by row, and send true only where you had proof. An unverified account also cannot register a passkey, enrol MFA or link a provider until it is verified.
What does not come over
| Thing | What to do |
|---|---|
| Sessions and refresh tokens | Everyone signs in once on the new system. Imported users arrive with no session. |
| MFA secrets and backup codes | Not importable. Users enrol again after sign-in; a verified account can do so straight away. |
| Passkeys | Not importable. Each passkey is bound to the relying party, so it would not have worked anyway. |
| Password changes made after export | Stay in the old system. Freeze those routes before you export. |
| OAuth links already held by another account | The user is still created, and the identity is listed under unlinked. Their Google sign-in lands on the other account until you fix one of the two. |
| Hashes outside the limits | Refused for the whole batch. Omit passwordHash for those rows and resend. |
Roles must exist in the Application before the import, or the batch is refused with END_USER_ROLE_UNKNOWN. Rows without a role get the Application's default. Each created user emits a user.created webhook with via: "import", and imported users never receive the welcome email. The workspace end-user quota applies per row: a 500-row batch on a workspace with 10 slots left creates 10 and reports the rest as skipped with reason quota_exceeded.

Limits and throughput
- 500 users per call, 1 at minimum.
passwordHash20 to 512 characters;emailup to 254;roleup to 40; up to 10 OAuth identities per user;metadataup to 16 KB.- A secret key is rate limited at 30,000 requests a minute by default. At 500 per call, 40,000 users is 80 calls, which is nowhere near it. Rows are written one at a time inside each call, so run batches in sequence rather than firing them all at once.
- Capacity matters on the cutover morning rather than during the import: roughly six to ten bcrypt sign-ins a second per API process until the hashes have been upgraded, then the argon2id path.
If you are moving billing too
Subscriptions sold before Rekey was connected can come over as well. Rekey reads a paginated endpoint you host, records per row what would happen and why, and writes nothing until an operator applies the preview behind a typed confirmation. An import never overwrites a live subscription and never matches an erased user. It is described in the subscription import guide.
Cutover checklist
- Create the roles your export uses in the Application.
- Mint a secret key with
auth:write, scoped to the IP you import from if you use allowlists. - Freeze password change and reset in the old system.
- Export. Map the verified flag to
emailVerified. Strip framework prefixes from hashes. - Dry-run one batch of 10 against a development Application. Check the hashes pass validation.
- Import in batches of 500 with an
Idempotency-Keyper batch. Keep everyskippedandunlinkedentry. - Compare: rows exported equals created plus skipped.
- Switch sign-in to Rekey. Keep a test account from the export and sign in with it; confirm it works and, if you self-host, that its hash now starts with
$argon2id$. - Handle
503 PASSWORD_VERIFY_BUSYwith a retry in your sign-in handler. - Watch
user.createdwebhooks and the End-users list for the first day. - Drop the old table when the week has been quiet.
No gradual dual-write
If you ever need to leave again
The same property that let you bring the hashes in lets you take them out. Self-hosted, the users live in your own Postgres in a schema you can read, hashes included. Every end-user also has a per-user data export (GET …/end-users/:euid/export) and a GDPR erasure that tombstones the person while keeping anonymised financial records, covered in the data erasure guide. The full import reference, including the unverified-account rule, is in the auth guide, and the SDK surface in the SDK docs.
Was this page helpful?
