Why Rehashing Passwords at Login Beats a Bulk Migration
You can't write a migration that upgrades your password hashes, because a hash is one-way and you don't have the passwords. Every "let's move from bcrypt to Argon2id" plan hits that wall on day one. The way through is to upgrade each hash at the only moment the plaintext exists: when the user logs in.
The wall: a migration has nothing to feed the new hash
A new hash function needs the password as input. Your database holds the output of the old function, which is exactly what you designed it to be unable to reverse. So a SELECT ... UPDATE loop over the users table has nothing to work with.
The user types the password once per session, and your login handler already runs the expensive verification. That is the one moment you hold the plaintext legitimately. Verify against the old hash, and if it passes, write a new hash. Users you never see again keep the old hash, which is no worse than before.
What "needs rehash" actually means
Store hashes in a self-describing format (the PHC string format, as used by most Argon2 libraries) so each record carries its own algorithm and parameters. Then "out of date" is a comparison between the record and your current settings. Reasons a hash can be stale:
- the algorithm changed (bcrypt to Argon2id)
- the cost parameters went up (memory, iterations, parallelism)
- the salt or output length changed
If your existing hashes are not self-describing, add a scheme column first. Guessing the algorithm from the string's shape works until it doesn't.
The code
This is Go with golang.org/x/crypto/argon2. The parameters are illustrative; pick your own by measuring on your hardware.
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
type params struct {
m, t uint32
p uint8
}
var current = params{m: 64 * 1024, t: 3, p: 4}
func hash(pw string, pr params) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(pw), salt, pr.t, pr.m, pr.p, 32)
enc := base64.RawStdEncoding
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, pr.m, pr.t, pr.p,
enc.EncodeToString(salt), enc.EncodeToString(key)), nil
}
func parse(s string) (pr params, salt, key []byte, err error) {
parts := strings.Split(s, "$")
if len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" {
return pr, nil, nil, errors.New("unsupported hash")
}
if _, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &pr.m, &pr.t, &pr.p); err != nil {
return
}
enc := base64.RawStdEncoding
if salt, err = enc.DecodeString(parts[4]); err != nil {
return
}
key, err = enc.DecodeString(parts[5])
return
}
func verify(pw, stored string) (ok, stale bool) {
pr, salt, key, err := parse(stored)
if err != nil {
return false, false
}
got := argon2.IDKey([]byte(pw), salt, pr.t, pr.m, pr.p, uint32(len(key)))
return subtle.ConstantTimeCompare(got, key) == 1, pr != current
}
The stale flag is a plain struct comparison, which works because params is comparable. The login handler then does the upgrade only after a successful check:
func (s *Store) Login(ctx context.Context, id int64, pw string) (bool, error) {
var stored string
err := s.db.QueryRowContext(ctx,
`SELECT pw_hash FROM users WHERE id = $1`, id).Scan(&stored)
if err != nil {
return false, err
}
ok, stale := verify(pw, stored)
if !ok {
return false, nil
}
if stale {
if nh, err := hash(pw, current); err == nil {
// Only replace the hash we just verified against.
_, err = s.db.ExecContext(ctx,
`UPDATE users SET pw_hash = $1 WHERE id = $2 AND pw_hash = $3`,
nh, id, stored)
}
if err != nil {
s.log.Warn("rehash failed", "user", id, "err", err)
}
}
return true, nil
}
Three details that bite
- Never fail the login because the rehash failed. The user proved who they are; a database hiccup on the upgrade is a log line, not a 500.
- Never rehash on a failed check. Otherwise you would be writing hashes of wrong guesses over real ones.
- The
AND pw_hash = $3guard matters. If the user changes their password in another tab between your read and your write, an unguarded update silently reverts them to the old password.
Also, the rehash costs one extra Argon2 computation on that login. That is a one-off per user, but a big parameter bump plus a Monday-morning login rush can spike CPU. Limit concurrent hashing if you are near capacity.
Quick detour: verifying a legacy hash you no longer generate
Hang on, doesn't this mean keeping the old algorithm's code forever? Yes, for as long as old hashes exist. In the handler, dispatch on the prefix: a string starting $2 goes to your bcrypt verifier, $argon2id$ to the new one, and anything matched by the old scheme always counts as stale.
That old verifier is now attack surface you maintain, which is a good reason to put a date on the migration rather than let it drift.
What about the users who never come back?
Login-time upgrades leave a tail of dormant accounts on the old hash. In a breach those are the ones cracked first. You have two honest options:
- Wrap the old hash. Run every stored hash through the new function, so the record becomes
argon2id(bcrypt_hash), and mark it as wrapped. At login, compute the old hash first, feed it to the new one, verify, then replace the record with a plain new hash. This is a real bulk migration that needs no plaintext, at the cost of a more awkward verify path. - Retire the account. After a cut-off date, invalidate remaining old hashes and force a password reset by email. This is simple and it also cleans out abandoned accounts.
If you handle UK personal data, keeping dormant accounts around at all is worth questioning under the data minimisation and storage limitation principles in UK GDPR. That is my reading rather than a regulator ruling on password hashes, and it depends on what you told users about retention.
Checking it worked
Add a metric or a periodic query counting hashes per scheme and parameter set. Watch it fall. When the count of old-scheme hashes flattens out, that is your cue to pick between wrapping and retiring, and once it hits zero you can delete the legacy verifier.