//← Back to Build Log

Lazy Password Migrations: Upgrade Your Hash Algorithm Without Anyone Noticing

How I moved a Spring Boot app's password hashes from salted SHA-256 to Argon2id with no forced resets, no batch jobs, and no downtime, by migrating each user on their next login.

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 comes out naturally from the way password hashing is handled.

Note: the examples below are in Spring and Kotlin, but the pattern holds up for whatever frameworks or languages you're using.

Why salted SHA-256 is the wrong tool for passwords

The one-line caveman version: SHA-256 too fast.

Fast is great for hashing files and verifying signatures, but it is not what you want from a password hash. A modern GPU will churn through something like 10 billion SHA-256 hashes a second, and a budget box with four consumer cards rips through enormous candidate lists in an afternoon.

Salting helps, but only against one specific attack. A per-user salt makes every stored hash unique, so an attacker who dumps your database cannot precompute one rainbow table and look everyone up at once. What the salt does not do is slow down a brute force against a single target. If someone is after one account, say the CEO, they grab that user's salt, start appending guesses, and still get their 10^10 attempts per second on a single GPU.

So salted SHA-256 turns "crack the whole database at once" into "crack each user one at a time." That is better, but each individual user is still cheap to crack if their password is not strong.

The real fix is to make the hash deliberately slow and memory-hungry. A password hash should take somewhere in the 100 to 500 ms range on normal hardware. That is practically invisible during an interactive login, but it is 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

After some initial research, I narrowed the candidates down to three serious options.

bcrypt (1999) is the grandparent. One cost parameter you raise over time, mature, battle-tested, and built into Spring Security. Its weakness is that it is not memory-hard, so GPUs and ASICs parallelize against it well. It is still a perfectly fine choice for most apps today. It just scales worse than the newer options as attacker hardware improves.

scrypt (2009) is memory-hard. It forces the attacker to allocate real RAM per attempt, which neutralizes the GPU's main advantage. It deserves more library support than it gets, but it is solid.

Argon2 (2015) won the Password Hashing Competition and comes in three flavors:

  • Argon2d is the fastest, but its data-dependent memory access makes it vulnerable to side-channel attacks.
  • Argon2i is side-channel resistant, but slower and a bit weaker against GPUs.
  • Argon2id is the hybrid. It runs Argon2i for the first pass, then switches to Argon2d for the bulk work, getting both the side-channel resistance and the GPU resistance. This is the one you want.

OWASP's current top pick for new applications is Argon2id. It is memory-hard like scrypt, it has three tunable knobs, and the design has now held up to about a decade of scrutiny.

The tradeoffs

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, it wouldn't have been, but the marginal cost of stepping up to the OWASP-recommended option was tiny. We already had the necessary BouncyCastle dependency registered for an existing integration, so there was no real reason not to go with it. Otherwise, bcrypt at cost 12 is a defensible answer.

As for scrypt, I gave it the least thought. It is memory-hard and genuinely solid, but Argon2id gives me the same memory-hardness with a cleaner set of parameters and the explicit OWASP recommendation for new applications. Spring Security supports both. Choosing scrypt over Argon2id would have meant taking the older, less-recommended option for no upside I could name. If you are already standardized on scrypt there is no reason to change, but starting fresh I had no reason to reach for it.

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.

OWASP's 2024 interactive baseline is 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).

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.

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.

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)
}

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.

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.

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.persist(PasswordVerification(userId, token, OffsetDateTime.now()))
        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.

You can watch progress with one query:

-- 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.

A free bonus: self-invalidating reset tokens

I got one nice side effect for free. Our 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 expiry bookkeeping, and no per-token state. The signing scheme enforces it on its own.

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.

Why I like this pattern

Pulling it all together:

  1. No new plaintext exposure. The only moment you hold plaintext is during verification, which is also the only moment you can rehash. The pattern fits the constraint exactly instead of fighting it.
  2. No forced resets. That avoids the reset email, the support tickets, and the UX speed bump in one shot.
  3. No batch jobs. A background script cannot rehash without plaintext, so it would be useless anyway.
  4. Self-paced. The system migrates at exactly the rate users return.
  5. Reusable. The same scaffold handles a future "Argon2id old params to Argon2id new params" bump, or "Argon2id to whatever wins the next competition." You add one branch to this same dispatcher and move on.

That last one is the real reason to build this properly. Run-once migration scripts get deleted the next day. This one sticks around and earns its keep every time the hashing needs to change again, which it will. Write it once and forget it's there.

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.

Party mode enabled!