SHA-256 vs MD5: How Hash Collisions Broke MD5 and Why It Still Matters
Hash functions are everywhere: Git commit IDs, file integrity checks, digital signatures, blockchain, password storage. Two names dominate the conversation โ MD5 (broken, but still widely used) and SHA-256 (the current workhorse). Understanding why MD5 fell and what "collision resistance" actually means will change how you think about every system that depends on hashing.
What a Cryptographic Hash Function Promises
A cryptographic hash function H takes arbitrary input and produces a fixed-size output (the digest), with three security properties:
- Preimage resistance: given a hash
h, it's infeasible to find inputmwhere H(m) = h. (You can't reverse it.) - Second-preimage resistance: given input
m1, it's infeasible to find a differentm2where H(m1) = H(m2). - Collision resistance: it's infeasible to find any two different inputs
m1 โ m2where H(m1) = H(m2).
Collision resistance is the strongest property โ and the one MD5 lost.
Note the birthday paradox implication: for an n-bit hash, collisions become likely after ~2^(n/2) inputs. MD5's 128-bit output means collisions are expected after ~2^64 hashes โ large, but within reach of determined attackers with modern hardware. SHA-256's 2^128 birthday bound remains far beyond practical attack.
MD5: How It Fell
2004: Wang's Breakthrough
At Crypto 2004, Xiaoyun Wang and colleagues demonstrated practical MD5 collisions โ two different messages producing the same MD5 hash. This wasn't theoretical: they produced actual colliding message pairs. The attack exploited weaknesses in MD5's compression function, finding collisions in hours on commodity hardware.
2008: The Rogue CA
The most dramatic real-world exploit came in 2008. Researchers (led by Marc Stevens) used MD5 collisions to create a rogue Certificate Authority. Here's the simplified attack:
- They requested a legitimate certificate from a CA that still used MD5 for signing.
- They crafted the certificate request so that the "to-be-signed" portion collided (under MD5) with a different certificate containing
CA:TRUE(certificate authority privileges). - The CA signed the benign-looking request, but the signature was equally valid for the rogue CA certificate.
- Result: a fully trusted CA certificate the attackers controlled, capable of issuing valid certificates for any domain.
This single attack killed MD5 for certificate signing overnight. Browsers and CAs migrated to SHA-256.
Why MD5 Collisions Are Easy Now
Modern collision attacks on MD5 take seconds on a laptop. Tools like hashclash can generate colliding PDFs, executables, or certificates on demand. The attack doesn't recover the original input (preimage resistance is technically still intact for random inputs), but for any application depending on "different inputs โ different hashes," MD5 is broken.
SHA-256: The Current Standard
SHA-256 (part of the SHA-2 family, FIPS 180-4) produces a 256-bit digest using the Merkle-Damgรฅrd construction:
- Padding: input is padded to a multiple of 512 bits, with the original length appended.
- Compression: each 512-bit block is processed through 64 rounds of bitwise operations (rotations, XORs, modular additions) mixing the block with the running state.
- Output: the final 256-bit state is the digest.
No practical collision attack exists against SHA-256. The best known attacks only work on reduced-round variants (not the full 64 rounds), and even those are far from practical.
import hashlib
# SHA-256 of a string
data = b"hello world"
print(hashlib.sha256(data).hexdigest())
# 2aae6c35c94fcfb415dbe95f408b9ce91ee846ed6620201e3b7757e8a948c2a0
# SHA-256 of a file (streaming, for large files)
def sha256_file(path: str) -> str:
h = hashlib.sha256()
with open(path, 'rb') as f:
for chunk in iter(lambda: f.read(8192), b''):
h.update(chunk)
return h.hexdigest()
# MD5 for comparison (NOT for security use)
print(hashlib.md5(data).hexdigest())
# 5d41402abc4b2a76b9719d911017c592
Verifying File Integrity
The most common legitimate use of hashing โ verifying downloads:
# Compare against published checksum
$ sha256sum ubuntu-24.04.iso
e240e4b72f67bb2e863b209f27c60d46... ubuntu-24.04.iso
# On macOS
$ shasum -a 256 ubuntu-24.04.iso
If even one bit changes, the hash is completely different (avalanche effect). This detects corruption and tampering โ but only if you get the expected hash from a trusted source.
Critical Distinction: Hashing vs Encryption
This confusion causes real security incidents:
| Hashing | Encryption | |
|---|---|---|
| Reversible? | No (one-way) | Yes (with key) |
| Purpose | Integrity, fingerprinting | Confidentiality |
| Same input โ same output? | Always (deterministic) | Depends (may use IV/nonce) |
| Key required? | No | Yes |
Hashing is not encryption. A SHA-256 hash of a password doesn't "encrypt" it โ but unlike encryption, there's no key to decrypt it. (Though weak passwords can still be brute-forced by hashing guesses.)
Passwords: Why Not Just SHA-256?
A common mistake: stored = SHA256(password). This is vulnerable to:
- Rainbow tables: precomputed hashes for common passwords. SHA-256("password123") is the same for every user on every system โ look it up in a table.
- GPU brute force: modern GPUs compute billions of SHA-256 hashes per second. SHA-256 is designed to be fast, which is exactly wrong for password hashing.
- No salt: identical passwords produce identical hashes, revealing which users share passwords.
The fix is password hashing functions designed to be slow and salted:
| Function | Why it's better |
|---|---|
| bcrypt | Adaptive cost factor; salt built in; GPU-resistant |
| scrypt | Memory-hard; resistant to ASIC/GPU |
| Argon2 | Winner of the Password Hashing Competition; memory-hard + tunable |
# WRONG: fast hash, no salt
import hashlib
stored = hashlib.sha256(b"password123").hexdigest() # Crackable in milliseconds
# RIGHT: bcrypt with automatic salt and cost factor
import bcrypt
hashed = bcrypt.hashpw(b"password123", bcrypt.gensalt(rounds=12))
# $2b$12$... (includes salt and cost; verification handles it)
assert bcrypt.checkpw(b"password123", hashed)
Rule: never use a general-purpose hash (MD5, SHA-1, SHA-256) for password storage. Use bcrypt, scrypt, or Argon2.
HMAC: Hashing with a Key
When you need both integrity and authentication (proving who created the hash), use HMAC:
HMAC(K, m) = H((K โ opad) || H((K โ ipad) || m))
HMAC-SHA256 is used in JWT signatures, API request signing, and webhook verification. Unlike a plain hash, an attacker can't forge an HMAC without the secret key.
import hmac, hashlib
def sign_webhook(payload: bytes, secret: bytes) -> str:
return hmac.new(secret, payload, hashlib.sha256).hexdigest()
# Verify (use compare_digest to prevent timing attacks)
def verify_webhook(payload: bytes, secret: bytes, signature: str) -> bool:
expected = sign_webhook(payload, secret)
return hmac.compare_digest(expected, signature)
When Is MD5 Still OK?
MD5 is broken for security purposes, but it's still fine for non-security uses where collisions don't matter:
- โ Checksums for accidental corruption (not adversarial tampering)
- โ Hash tables / data partitioning (where collisions just cause rehashing)
- โ File deduplication (with a fallback full comparison)
- โ Digital signatures
- โ Certificate signing
- โ Password hashing
- โ Any integrity check against a malicious adversary
If an attacker could benefit from crafting a collision, don't use MD5.
Try It
Need to compute hashes quickly without writing a script? DailyToolbox's free hash generator runs entirely in your browser: paste text and get MD5, SHA-1, SHA-256, SHA-512, and more instantly. For keyed hashing, the HMAC generator handles the key mixing correctly.
๐ Try the Hash Generator โ
FAQ
Is SHA-256 quantum-safe?
Not entirely. Grover's algorithm gives quantum computers a quadratic speedup against hash functions, effectively halving the security level (SHA-256 โ ~128-bit quantum security). This is still considered safe for the foreseeable future, unlike RSA/ECC which Shor's algorithm breaks completely. NIST is standardizing quantum-resistant signatures, but hash functions are less urgent.
Why do Git commit hashes start with the same characters sometimes?
Git uses SHA-1 (transitioning to SHA-256). Short prefixes collide by the birthday paradox โ with millions of commits, 7-character prefixes will occasionally match. Git handles this by extending the displayed prefix until it's unique. This is a usability quirk, not a security issue (though Git's move to SHA-256 addresses the theoretical collision concern).
Can two different files really have the same SHA-256 hash?
Theoretically yes (pigeonhole principle โ infinite inputs, finite 256-bit outputs). Practically, no one has ever found a SHA-256 collision, and the birthday bound (2^128 operations) is far beyond any feasible computation. For all engineering purposes, SHA-256 collisions don't happen.
Should I use SHA-3 instead of SHA-256?
SHA-3 (Keccak) is a different construction (sponge vs Merkle-Damgรฅrd) and is a fine choice for new systems. But SHA-256 remains secure and is more widely supported. There's no urgent reason to migrate existing SHA-256 deployments. Choose SHA-3 for greenfield projects if you prefer its design, but don't rewrite working SHA-256 code.