Migrating off MD5 and SHA-1 safely

Inventorying where a retired hash is used, dual-hash transition periods, rehashing on write, schema and protocol changes, and a rollback plan that actually works.

Know what you are replacing

UseRisk if brokenMigration difficulty
Password storageCritical — offline crackEasy: rehash on login
Digital signatureCritical — forgeryHard: re-issue signatures and certificates
Message authenticationCritical — forged messagesMedium: dual-verify during rollout
File integrity published for downloadHigh — substituted contentMedium: republish digests
Cache keyLow — a collision causes a wrong cache hitEasy: version the key prefix
Deduplication keyLow to medium — data loss if two records mergeHard: requires a full reindex
Content address for stored objectsHigh — content substitutionHard: rewrite object ids and references
# find the usage sites before changing anything
grep -rIn --exclude-dir=.git -E "\b(MD5|SHA1|sha-1|sha1|md5)\b" . | head -50
grep -rIn "hashlib.new\|MessageDigest.getInstance\|crypto.createHash" . | head -50

# classify by datastore too: hashes already written to disk or a database
rg -n "md5|sha1" db/migrations/ config/ 2>/dev/null

Dual-hash transition

A big-bang switch breaks every stored value at once. The safe pattern is to store both digests, verify against either, and write the new one from now on.

import hashlib

def upgrade_checksum(record, data: bytes):
    """Store both digests during the transition window."""
    record.sha256 = hashlib.sha256(data).hexdigest()
    if record.md5 is None:
        record.md5 = hashlib.md5(data).hexdigest()      # legacy value, still readable
    return record

def verify(record, data: bytes) -> bool:
    if record.sha256:
        return hashlib.sha256(data).hexdigest() == record.sha256
    if record.md5:
        return hashlib.md5(data).hexdigest() == record.md5
    return False
  • Add the new column as nullable so existing rows are unaffected and no backfill is needed to deploy.
  • Backfill in batches, or lazily on read and write, whichever keeps the migration observable.
  • Log the count of rows still relying on the old digest, and set an alert so the migration cannot stall silently.
  • For protocols, negotiate: advertise both algorithms and accept either until the peer announces support for the new one.
  • Announce an end date for the old algorithm internally, and remove the verification path on that date.

Rollback and verification

  1. Keep the old digest column populated until the new one covers every row; never null it as part of the upgrade.
  2. Deploy verification-with-fallback before writing any new digests, so a rollback cannot lock anyone out.
  3. Test the rollback path explicitly: revert the writer, confirm the old reader still works, then re-deploy.
  4. Verify a sample: recompute the new digest for random rows and compare with the stored value.
  5. Only drop the legacy column after a full retention window with zero fallback reads.
def audit_sample(limit=1000):
    rows = db.sample_records(limit)
    mismatches = [r.id for r in rows if r.sha256 and
                  r.sha256 != hashlib.sha256(r.data).hexdigest()]
    stats.gauge("checksum.mismatches", len(mismatches))
    if mismatches:
        raise RuntimeError(f"{len(mismatches)} checksum mismatches: {mismatches[:5]}")
⚠️
Do not null out the legacy digest as a cleanup step before the new one is complete. That single migration removes your only fallback, and every row it touches becomes unverifiable if the new path has a bug.

FAQ

Can I just rehash on read?
For password storage where the plaintext is available at login, yes. For file or message integrity you only have the stored digest, so you must have the data or the data must be re-readable.
How long should the transition window be?
Long enough for every writer and reader to be redeployed, plus a full retention period. Tie the end date to a measurable metric such as fallback-read count reaching zero.

Password hashing: bcrypt, scrypt and Argon2 Salts, peppers and work factors

Last refreshed 2026-09-18.