In this article
June 16, 2026
June 16, 2026

Password hash migration: Formats, salting, and silent rehashing

When you migrate auth providers, you inherit password hashes you can't decrypt. Here's how to handle every major format.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

The gnarliest part of switching identity providers isn't SSO config or user metadata. It's the password hashes. You can't decrypt them, you can't translate them, and your new system has to verify logins against whatever format the old system happened to use. This post catalogs the hash formats you'll actually encounter when migrating off Auth0, Firebase Auth, Cognito, and friends, and walks through the silent rehash pattern we use to upgrade users without ever sending a password reset email.

Why password hashes are special

Most data migrations are boring. Export from system A, transform, import into system B. Password hashes break that model because hashes are one-way by design. There is no "export plaintext and re-import" path. If there were, your old provider would be storing plaintext passwords, which is a much bigger problem than your migration.

That leaves you with two real options:

Force every user to reset their password on cutover. Bad UX, suppresses login activity during the cutover window, and the security team will dislike the flood of reset emails that look indistinguishable from phishing.

Import the existing hashes as-is and verify against them on next login, then transparently upgrade to your preferred algorithm.

Option two is the silent rehash pattern, and it's the only humane answer for any product with real users, especially the long tail of users who haven't logged in for months and won't notice a migration happened at all.

The formats you'll actually see

Every major identity provider stores hashes a little differently. The algorithm matters, but so do the encoding, the salt strategy, and the parameter format. Here's what you'll actually encounter.

Bcrypt

The workhorse. You'll see it from Auth0, Firebase Auth (for legacy imports), and a long list of homegrown systems. The format is self-describing:

  
$2b$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
 ^   ^  ^                   ^
 |   |  |                   31-char hash
 |   |  22-char salt (embedded)
 |   cost factor (work factor = 2^12 iterations)
 version (2b)
  

Cost factor lives in the string, salt is embedded, no out-of-band metadata required. This is the easiest case.

Scrypt (Firebase)

Firebase Auth's native format is a customized scrypt variant. It's scrypt, but with Firebase-specific parameters that you have to carry over verbatim or verification fails:

  
# Hash string (from user export)
WKCwT7HFtqHPKCkFMjFAzA==

# Parameters (from project export, separate JSON blob)
{
  "hashAlgo": "SCRYPT",
  "hashConfig": {
    "signerKey": "jxspr8Ki0RYycVU8zykbdLGjFQ3McFUH0uiiTvC8pVMXAn...",
    "saltSeparator": "Bw==",
    "rounds": 8,
    "memCost": 14
  }
}
  

If you're exporting from Firebase, you need all of those parameters, not just the hash. The signer key and salt separator are project-level constants; the rounds and memCost tune the algorithm. Miss any one and verification fails silently, with no error message that points you to the cause.

PBKDF2 (Cognito)

AWS Cognito uses PBKDF2-SHA256, and exports come with iteration count and salt as separate fields:

  
# Hash (base64-encoded)
xCTHmv+Oo5PRLE1kyPFiow==

# Separate metadata fields
salt: 4VGNoAqhCmxmzFZMvzZ3Lw==
iterationCount: 100000
  

Straightforward to verify if your new system supports PBKDF2 directly. The thing to watch is the iteration count: Cognito's default has changed over time, so don't assume a single value across all your users.

Argon2

The modern recommendation, and what you probably want to rehash to. Format is self-describing like bcrypt:

  
$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
 ^         ^    ^               ^             ^
 |         |    |               |             hash (base64)
 |         |    |               salt (base64)
 |         |    memory=65536 KB, time=3 iter, parallelism=4
 |         version
 variant (argon2id)
  

Parameters travel with the hash, which makes it portable. When choosing target parameters, run benchmarks on your actual hardware. The defaults in most libraries are conservative; you can likely push memory and time higher.

When you export from any provider, you need the algorithm identifier, the full parameter set, the salt, and the hash. Miss any one of those and the import is dead on arrival.

Salting strategies that trip people up

Most modern algorithms (bcrypt, Argon2, scrypt) embed the salt in the hash string. You don't have to do anything special; the salt rides along.

The painful cases are older or custom systems where the salt is stored separately or, worse, derived from something about the user. Here's a real pattern we've encountered:

  
# Salt derived from user attributes, not stored anywhere
def make_salt(user):
    return f"{user.email}:{user.created_at.strftime('%Y%m%d')}"

stored_hash = sha256(make_salt(user) + plaintext_password)
  

Nothing about the salt is in the hash string. It's not in a separate column. It's reconstructed at verification time from user attributes that may themselves change. An email update will silently break login for every affected user, and there's no error that points you to why. None of that survives a naive export, and there's no way to detect the problem from the hash alone.

Before you start, confirm three things about every user record:

  1. Which algorithm produced this hash?
  2. What are the full parameters (cost, iterations, memory, parallelism)?
  3. Where does the salt live, and is it static or derived?

If you can't answer all three for every row, stop and figure it out before you import anything. Run a verification pass against a sample of active users in your old system first. If you can't verify 100% of your sample, something is wrong with your parameter reconstruction.

The silent rehash pattern

Here's the pattern that makes this work without resetting anyone's password.

  1. Import the existing hash verbatim, tagged with its algorithm and parameters. Store it alongside (not instead of) a column for your preferred algorithm.
  2. On next login, the user submits their plaintext password. Verify it against the imported hash using the original algorithm.
  3. If verification succeeds, you have the plaintext password in memory for the duration of the request. Immediately hash it with your preferred algorithm (Argon2id, usually) and write that to the new column.
  4. Mark the old hash as superseded. On subsequent logins, verify against the new hash only.

The user notices nothing. Their password didn't change. Their session works. The stored credential has been transparently upgraded from, say, bcrypt cost 10 to Argon2id with modern parameters.

A few implementation details that matter:

  • Do the rehash in the same request that verified the login. Don't queue it. You have the plaintext in memory exactly once. If you defer to a background job, you have to pass the plaintext somewhere, which means it's now in your job queue, your logs, or your message broker. That's worse than the problem you're solving. Use it now.
  • Wrap the rehash write in a transaction with the login event, and treat failure as non-fatal. If the write fails, the user still logged in successfully. Log it, let them through, and they'll get another shot next time. What you don't want is to fail the login because the rehash write failed. Verification succeeded; that's the contract. The rehash is a background upgrade, not a login gate.
  • Use constant-time comparisons on both paths. The legacy verifier is just as much an attack surface as the new one. A timing oracle against your bcrypt verifier is still a timing oracle.

The long-tail users

What about users who don't log in for six months? A year? Ever again?

You have three reasonable policies, and which one you pick is a product decision more than an engineering one:

  • Carry the legacy hashes forever. Cheap, but you're stuck supporting verifiers for every algorithm you ever imported. This is more maintenance burden than it sounds over a three-year horizon.
  • Set a sunset date. Announce that on date X, any user who hasn't logged in since will receive a password reset email. Clean, but you'll lose some users who never open it. Pair this with a re-engagement campaign if retention matters.
  • Sunset by risk. Keep low-risk legacy hashes (recent bcrypt at cost 12 or higher) indefinitely and force-reset only the genuinely weak ones: low-iteration PBKDF2, MD5-based crypt, SHA1 without stretching. This is usually the right answer because it concentrates disruption on users whose credentials were already worth worrying about.

Whichever you pick, instrument it. You want a dashboard showing, per algorithm, how many users are still on the legacy hash and the trend line over time. That graph going to zero is how you know the migration is actually done.

What to take away

Password hash migration is a solved problem, but only if you treat the export side as carefully as the import side. Know your formats. Carry the parameters. Confirm your salt strategy before you touch a single row. Verify before you rehash. Upgrade silently. Keep watching the long tail until it's gone.

If you're staring down a migration off Auth0, Firebase Auth, or Cognito and trying to figure out what your export actually contains, start by dumping a single user record and matching every field to the format spec above. The migration gets a lot less scary once you can read what you have.