//← Back to Undefined Behavior

Migrating Password Hashes Without Anyone Noticing

A walkthrough of seamlessly upgrading user password hashing algorithms with no forced resets, no batch jobs, and no downtime in a Spring Boot application.

Most security upgrades come with a tax. You change something under the hood and your users feel it. A password-hash upgrade is the rare one where that tax is optional, but most teams pay it anyway by firing off a "please reset your password" email to the entire user base. That email tanks engagement and fills the Monday support queue with "why did you change my password???"

When I took over an existing Spring Boot codebase, one of the first things I did was a security review of what I was inheriting. One item I flagged as a top priority was password storage. The app hashed passwords with salted SHA-256, a solid choice years ago that has aged like milk. The hard part was never picking a replacement. It was rolling one out without forcing every user to reset their password.

I moved password-hashing to Argon2id without sending a single one of those emails. Every user kept their existing password, nothing ran as a batch job, and the app never went into a maintenance window. As far as our users could tell, nothing happened at all. Here is how the pattern works and why it flows naturally from the way password hashing is handled.

Note: the examples below are in Spring and Kotlin, but the pattern holds up whatever framework or language you're using. They're simplified to show the shape of the approach, not lifted out of a production codebase. Always do your own research and validation when performing critical security updates rather than blindly following what is presented here.

Why salted SHA-256 is the wrong tool for passwords

SHA-256's fatal flaw as a password hash is that it's fast. A modern GPU churns through something like 10 billion SHA-256 hashes a second, so a budget box with four consumer cards can grind an enormous candidate list in an afternoon. Salting doesn't fix that. A per-user salt stops one precomputed rainbow table from unlocking everyone at once, but it does nothing to slow a brute force against a single account, and each user is still cheap to crack if their password is weak.

The real fix is a hash that's deliberately slow and memory-hungry, 100 to 500 ms per attempt: practically invisible during an interactive login, brutal for an attacker who needs billions of guesses. The technique is called adaptive hashing, and the "adaptive" part means you can crank the cost up as hardware gets faster.

Picking a secure password hash

Three candidates were worth taking seriously. bcrypt (1999) is mature, battle-tested, and built into Spring Security, but it is not memory-hard and it silently truncates anything past a 72-byte input limit. scrypt (2009) is memory-hard and genuinely solid, but under-supported by libraries. Argon2id (2015) is the hybrid Argon2 variant that won the Password Hashing Competition: memory-hard, three tunable knobs, OWASP's current pick for new applications, and exactly what Spring Security's Argon2PasswordEncoder gives you.

bcrypt Argon2id
Cryptographic strength Solid Solid, plus memory-hard
GPU/ASIC resistance Modest Strong
Maturity (years deployed) 25+ ~10
Spring Security support Built-in, default Built-in (needs BouncyCastle)
Tunable parameters 1 (cost) 3 (memory, iterations, parallelism)
Hash output Self-contained, ~60 chars Self-contained, ~100 chars
OWASP recommendation Acceptable Recommended
Operational complexity Low Slightly higher

I went with Argon2id, not because bcrypt would have been wrong, but because the marginal cost of stepping up to the OWASP-recommended option was tiny and we already had the BouncyCastle dependency it needs registered for another integration. Otherwise, bcrypt at cost 12 is a defensible answer. scrypt got the least thought: same memory-hardness, older and less-recommended, no upside I could name for a fresh start.

You can't rehash what you can't read

My first instinct was the obvious one: write a migration script, loop over every user, rehash each password with Argon2id, done by lunch. That plan survived about thirty seconds. You cannot rehash a password without the plaintext, and the plaintext is the one thing a password table never stores. That is the whole point of a hash, and it makes the tidy batch job impossible, not just impractical. There is nothing to feed the new algorithm.

The only moment you ever hold a user's plaintext is the instant they type it to log in, so that is when the migration has to happen. You do not migrate users in a batch. You migrate each one on their next login, riding along on a verification step that was going to run anyway.

At any point during the rollout you have three groups:

  1. Active users log in regularly and migrate within a week or so.
  2. Occasional users migrate whenever they next show up, maybe a month or three out.
  3. Dormant users never come back and stay on the old hash indefinitely. That is fine. Their accounts are no more exposed than before, the hash is still valid, and if they ever return they migrate on the spot.

The dormant tail is not a security problem, it is at most a future UX decision. You can always force a reset of it sometime later if you prefer to leave no threads hanging.

A caveat on dormant legacy hashes

On calling the dormant tail a UX decision rather than a security risk: That holds for salted SHA-256, which still forces an attacker to grind each hash on its own, so the residual exposure on a dormant account is low enough. If your legacy hash is genuinely weak (unsalted, MD5, bare SHA-1), or you are in a high-stakes context, those dormant hashes are a real risk, since they can be cracked in bulk whether or not the user ever comes back. OWASP's Upgrading Legacy Hashes guidance covers this: expire the old hashes and force those users to reset, or layer the strong algorithm over the weak one. Layering sidesteps the no-plaintext problem from earlier, since you feed Argon2id the hash you already have instead of the password, storing argon2id(md5($password)) for every account in one pass and protecting even the dormant ones immediately. A layered hash is a bit weaker than a clean one, so treat it as a stopgap and still swap in a real argon2id($password) on the user's next login.

The lazy migration

The core idea in pseudocode:

for each successful login:
    if the user already has a modern hash:
        verify normally, return the result
    else:
        verify against the legacy algorithm
        if it matches:
            hash the plaintext with the modern algorithm
            store the new hash and clear the legacy one
        return the result

Two properties matter. First, failed logins migrate nobody: a wrong password behaves exactly as before, with no database writes, so brute-force attempts never migrate themselves into anything. Second, the user sees nothing. They typed a password, they got logged in, same as always, with no "we have updated our security, please confirm" prompt.

The code

Spring Security setup is one bean:

@Configuration
open class PasswordHashingConfig(
    @Value("\${security.argon2.memory-kib:19456}") private val memoryKib: Int,
    @Value("\${security.argon2.iterations:2}") private val iterations: Int,
    @Value("\${security.argon2.parallelism:1}") private val parallelism: Int,
) {
    @Bean
    open fun passwordEncoder(): PasswordEncoder =
        Argon2PasswordEncoder(16, 32, parallelism, memoryKib, iterations)
}

The first two arguments are easy to skim past: 16 is the salt length in bytes (a 128-bit salt) and 32 is the length of the hash output in bytes (256 bits). Both are Spring's defaults. The three named values after them are the ones that set the actual work factor.

That same encoder both creates and verifies Argon2id hashes. Argon2 hashes are self-describing, so on verification the encoder reads the parameters straight out of the stored string.

One thing the constructor does not show is which Argon2 variant you get. There is no argument for it, because Argon2PasswordEncoder is hardwired to Argon2id and gives you no way to pick Argon2i or Argon2d. If you ever want to confirm it, the variant is the first field of that self-describing string, so any stored hash beginning with $argon2id$ settles it.

Worth a quick aside: Spring's DelegatingPasswordEncoder has out-of-the-box scheme-switching for hashes stored in its prefixed {id} format. Our legacy hashes were a bare SHA-256 scheme with no prefix, so a small custom dispatcher was simpler than retrofitting the prefix onto every old row.

The data model has two homes during the migration:

  • The legacy columns on the user_account table: salt and password (the SHA-256 output), both made nullable in a migration.
  • A new user_verification table, one row per migrated user: user_id, token (the Argon2id hash), date_updated.

The presence of a user_verification row is the migration flag. No is_migrated boolean, no nullable enum. Either the row exists and you are on Argon2id, or it does not and you are still on SHA-256.

You do not need a second table for any of this. Migrating in place works fine. Widen the existing password column for the longer Argon2id hash and overwrite it on a successful login. That is simpler, and it is what most write-ups do.

I split the new hash into its own table for application-level separation. It keeps credentials off the main user entity (no leaking through a stray SELECT *, an API response, or a log line), lets me scope tighter database grants, and allows checking "who is migrated?" by a simple row query instead of matching hash prefixes. To be clear, it buys nothing against a security breach where both tables fall together. It is defense against accidental exposure, not a wall against an attacker who already has your database.

The dispatcher:

@Service
class PasswordVerificationService(
    private val passwordEncoder: PasswordEncoder,
    private val verificationRepo: PasswordVerificationRepository,
) {

    /**
     * Verifies that [plaintext] matches the stored password for [user].
     *
     * As a side effect, lazily migrates users still on the legacy hash to
     * Argon2id on successful verification. The migration is invisible to
     * the caller. They just see a Boolean.
     */
    fun verifyPassword(plaintext: String, user: User): Boolean {
        val stored = verificationRepo.findByUserId(user.id)

        if (stored != null) {
            // User has already been migrated to Argon2id.
            return passwordEncoder.matches(plaintext, stored.token)
        }

        // No verification row, so the user is still on legacy SHA-256.
        if (sha256Hex(user.salt + plaintext) != user.password) {
            return false
        }

        // Plaintext matches the legacy hash. Migrate to Argon2id while we have it.
        verificationRepo.insertAndClearLegacy(
            userId = user.id,
            token = passwordEncoder.encode(plaintext)
        )
        return true
    }
}

That is the whole engine. Two branches, no flag columns, no scheme-detection guesswork. Here is what happens per request:

User state Wrong password Correct password
Already migrated (Argon2id row exists) matches() returns false. No writes. matches() returns true. No writes.
Not yet migrated (legacy SHA-256) SHA-256 check fails. No writes. SHA-256 check passes, then hash with Argon2id and write the new row plus clear the legacy columns, in one transaction.

One honest weakness: the two failure paths do not cost the same. A wrong password for a migrated user runs the full Argon2id verify, while a wrong password for someone still on legacy fails on a fast SHA-256 compare. That timing gap leaks whether a given account has migrated yet, which is low value to an attacker but not nothing. If you care, run a throwaway Argon2id hash on that fast-fail path so both failures take about the same time. The path where the user is not found at all has the same shape and deserves the same treatment; that is out of scope here since verifyPassword already receives a User.

The repository layer is the boring part, an insert plus an atomic clear of the legacy columns:

@Repository
class PasswordVerificationRepository(private val em: EntityManager) {

    fun findByUserId(userId: Long): PasswordVerification? = ...

    @Transactional
    fun insertAndClearLegacy(userId: Long, token: String) {
        em.createNativeQuery("""
            INSERT INTO user_verification (user_id, token, date_updated)
            VALUES (:userId, :token, NOW())
            ON CONFLICT (user_id) DO NOTHING
        """).setParameter("userId", userId)
           .setParameter("token", token)
           .executeUpdate()

        // Idempotent: a concurrent login may have already nulled these.
        em.createQuery("""
            UPDATE User u
               SET u.salt = NULL, u.password = NULL
             WHERE u.id = :userId
        """).setParameter("userId", userId).executeUpdate()
    }
}

Both writes live in one transaction, so they commit together or roll back together. There is never a window where a user has both an Argon2id hash and a populated legacy hash. The dispatcher would behave correctly even if there were, since it checks for the verification row first, but clearing the old columns keeps the data clean and the "is this user migrated?" question crisp. If you're updating the hashes in place, then this happens automatically.

One concurrency edge: two simultaneous logins for the same un-migrated user can both read no verification row, both pass the SHA-256 check, and both attempt the insert. Put a unique constraint on user_verification.user_id and make the insert idempotent (ON CONFLICT (user_id) DO NOTHING as above, or catch the constraint violation and fall through to verifying against the now-present Argon2id row) so a duplicate is a no-op rather than an error.

This lets me track the progress in two simple queries.

-- migrated users
SELECT COUNT(*) FROM user_verification;

-- users still on legacy SHA-256
SELECT COUNT(*) FROM user_account WHERE password IS NOT NULL;

Watch those two numbers converge over the weeks after deploy.

Tuning Argon2id

Argon2 has three knobs:

  • memory: KiB allocated per hash. More memory, more resistance to parallel attacks.
  • iterations: passes over that memory. More passes, more time per hash.
  • parallelism: threads per hash. More threads is faster wall-clock but costs more CPU per login.

The OWASP Password Storage Cheat Sheet currently recommends a baseline of m=19456 KiB (19 MiB), t=2, p=1, which lands around 150 to 300 ms on commodity hardware. I ran the defaults through our test and staging environments to confirm logins still felt fast. This was more of a sanity check than rigorous latency profiling. If login latency is critical, measure it properly on production-like hardware and tune it yourself instead of relying on a default or a number from someone's blog (including this one). The same goes for the recommended parameters themselves. They move as hardware gets faster, so treat the numbers here as a snapshot and check the cheat sheet for the current guidance before you ship.

One note on parallelism: every concurrent login burns p threads. On a small, busy instance, leave p=1. The rough test for raising it is (spare cores at peak) ÷ (peak concurrent logins) ≥ p. For a typical low-login-throughput B2B app, p=1 is almost always right.

A free bonus: self-invalidating reset tokens

I got one nice side effect for free. Password-reset tokens are signed with a server secret concatenated with the user's current password hash:

// A signed token is just a payload plus an HMAC over it, keyed by some secret.
fun createToken(payload: String, secretKey: String): String {
    val body = base64UrlEncode(payload.toByteArray())
    val signature = base64UrlEncode(hmacSha256(body, secretKey))
    return "$body.$signature"
}

// Reset token: key the signature with the server secret PLUS the user's current hash.
fun createResetToken(user: User): String {
    val payload = """{"sub":"${user.email}","exp":$expiresAt}"""
    return createToken(payload, secretKey = serverSecret + currentPasswordHash(user))
}

The important piece is currentPasswordHash(user), which returns whatever the hash is right now, in whichever scheme:

fun currentPasswordHash(user: User): String =
    verificationRepo.findByUserId(user.id)?.token  // Argon2id (post-migration)
        ?: user.password                            // SHA-256 (pre-migration)

Because the signature is keyed by the current hash, anything that changes that hash invalidates every outstanding reset token for that user. Two things change it. A completed reset or password change rotates the hash, so the token that was just used stops verifying the instant the new password lands, which means it cannot be replayed. The migration counts too: when a user logs in and flips from SHA-256 to Argon2id, any reset link issued beforehand quietly dies along with the old hash. You end up with single-use reset tokens for free, with no consumed flag, no per-token rows, and no server-side state to track. The exp field in the payload handles expiry; the signing scheme handles the rest on its own. The one flip side: a reset link issued before migration silently dies if the user logs in and migrates before clicking it. That is fine; if the user could log in, the reset was not needed.

Aside: the real reset token also carries an action field, but that's orthogonal to the self-invalidation trick.

What about new users?

Nothing special. New signups run through the normal flow and get an Argon2id hash from day one, because the passwordEncoder.encode(...) call in the registration path writes straight to user_verification. They never touch the legacy branch. The "no verification row means SHA-256" path exists only for accounts that predate the migration.

Final notes

If you are sitting on fast legacy hashes, SHA-256, MD5, anything quick, lazy-on-login is the standard way out. It works, users never notice, and the same scaffold stretches to cover the next hash upgrade too. The hard parts are picking parameters and writing the dispatcher (which really isn't hard at all). The rest is config and patience.

The one thing to hold onto: a hash you cannot reverse can only be replaced when its owner hands you the plaintext again. Build around that constraint instead of against it, and the migration runs itself.

That is all for today. Go find out what your old hashes are actually made of.

//Comments