PBKDF2: Password-Based Key Derivation
For nearly two decades, PBKDF2's answer to password cracking was simple: make every guess expensive by repeating a hash thousands of times. Here's how it works, and why memory-hard successors like Argon2 eventually surpassed it.
Interactive PBKDF2 Visualizer
🔐 PBKDF2 Key Derivation
PBKDF2: Password-Based Key Derivation
Introduction
Passwords make terrible encryption keys directly: they’re short, guessable, and drawn from a much smaller space than a proper cryptographic key. PBKDF2 (Password-Based Key Derivation Function 2), standardized in RFC 2898 back in 2000, addresses this with a simple but effective idea: apply a pseudorandom function, almost always HMAC, many thousands of times in a row. That deliberately makes each password guess computationally expensive, rather than the near-instant single hash an attacker would otherwise get to try.
Table of Contents
- The Core Idea: Deliberate Slowness
- How PBKDF2 Works
- Choosing an Iteration Count
- A Verified Example
- Python Implementation
- Limitations
- PBKDF2’s Limitation, and What Replaced It
- FAQ
- References
The Core Idea: Deliberate Slowness
A single SHA-256 hash of a password takes a fraction of a microsecond. That means an attacker with a stolen password database and a GPU can try billions of guesses per second against unsalted, un-stretched hashes. PBKDF2’s fix: instead of hashing the password once, run HMAC repeatedly, often tens of thousands or hundreds of thousands of times, chaining each output into the next round’s input. A legitimate login only pays this cost once, adding a barely noticeable delay. An attacker trying billions of candidate passwords pays it billions of times over.
How PBKDF2 Works
For each 32-byte (or hash-output-sized) block of the derived key:
U1 = HMAC(password, salt || block_index)
U2 = HMAC(password, U1)
U3 = HMAC(password, U2)
...
Uc = HMAC(password, U(c-1))
Block = U1 XOR U2 XOR U3 XOR ... XOR Uc
where c is the configured iteration count. If more output bytes are needed than one hash produces, additional blocks are derived the same way with an incrementing block index, and the results concatenated. The XOR-of-all-iterations construction (rather than just using the final HMAC output) is a deliberate design choice. It ensures that even if an attacker could somehow predict or shortcut a later iteration, they’d still need the full chain to reconstruct the correct final value.
Interactive Visualizer
The visualizer above runs genuine PBKDF2-HMAC-SHA256 (real iterated HMAC, real XOR chaining), and it was verified against a standard cryptographic library’s PBKDF2 implementation before being published here. Try increasing the iteration count and watch how much longer the derivation takes, even for a tiny demo.
Choosing an Iteration Count
PBKDF2’s security is directly proportional to its iteration count: higher counts mean slower brute-forcing, but also slower legitimate logins. Modern guidance (OWASP, NIST) recommends iteration counts in the hundreds of thousands for PBKDF2-HMAC-SHA256. That count is a moving target, adjusted upward over time as hardware gets faster. It requires periodically re-hashing stored credentials with higher counts as recommendations increase, which is itself an operational burden PBKDF2 places on system maintainers.
A Verified Example
Using standard demonstration parameters:
- Password: “password”
- Salt: “salt123”
- Iterations: 1,000
- Output length: 32 bytes
produces a specific, deterministic 32-byte derived key. That’s exactly what the visualizer above reproduces, matching a standard cryptographic library’s PBKDF2-HMAC-SHA256 output byte for byte.
Python Implementation
The U1, U2, … Uc chain and the running XOR described above translate directly into a working implementation, built on the same HMAC-SHA256 from the HMAC guide’s Python Implementation:
import hashlib
BLOCK_SIZE = 64
def hmac_sha256(key: bytes, message: bytes) -> bytes:
if len(key) > BLOCK_SIZE:
key = hashlib.sha256(key).digest()
key = key + b'\x00' * (BLOCK_SIZE - len(key))
ipad = bytes(b ^ 0x36 for b in key)
opad = bytes(b ^ 0x5c for b in key)
inner_hash = hashlib.sha256(ipad + message).digest()
return hashlib.sha256(opad + inner_hash).digest()
def pbkdf2_hmac_sha256(password: bytes, salt: bytes, iterations: int, dklen: int) -> bytes:
hash_len = 32
num_blocks = -(-dklen // hash_len) # ceiling division
output = b''
for block_index in range(1, num_blocks + 1):
u = hmac_sha256(password, salt + block_index.to_bytes(4, 'big'))
result = bytearray(u)
for _ in range(iterations - 1):
u = hmac_sha256(password, u)
for i in range(hash_len):
result[i] ^= u[i]
output += bytes(result)
return output[:dklen]
if __name__ == "__main__":
password = b"password"
salt = b"salt123"
derived_key = pbkdf2_hmac_sha256(password, salt, iterations=1000, dklen=32)
print(f"Derived key: {derived_key.hex()}")
Running this with the exact parameters from the Verified Example above produces 5d7f40230571da961c9c8026f94ebdbe375422672f35e8470f96ad378af00dbd. I checked this implementation against Python’s own hashlib.pbkdf2_hmac('sha256', ...) before writing this up, and the two agree exactly.
Limitations
This uses hashlib.sha256 for the underlying hash (see the SHA-2 guide’s Python Implementation for a from-scratch version of that), so what’s left is the iteration and block-expansion logic PBKDF2 itself adds:
- Fixed to HMAC-SHA256. RFC 2898 defines PBKDF2 generically over any pseudorandom function; this code only implements the HMAC-SHA256 instantiation, by far the most common choice today.
- No constant-time comparison concerns, but also no timing hardening. Password comparison isn’t part of PBKDF2 itself, but this code makes no particular effort at constant-time behavior in the XOR loop either.
- Slow by construction, which is the point, but this makes it a poor demonstration of raw Python performance. A production system would call a native (C-implemented) PBKDF2, not a pure-Python loop like this one, specifically because the deliberate slowness described above should come from the algorithm’s design, not from interpreter overhead.
- Real applications should use
hashlib.pbkdf2_hmac, not this. This code exists to make the U1⊕U2⊕…⊕Uc chain inspectable, not to replace Python’s own standard library implementation, which is already correct and considerably faster.
PBKDF2’s Limitation, and What Replaced It
PBKDF2’s iteration-based slowdown has one significant weakness: it requires almost no memory to compute. That means GPUs and custom ASICs, extremely good at massively parallel arithmetic with minimal memory per unit, can still brute-force PBKDF2-protected passwords dramatically faster than a general-purpose CPU, even at high iteration counts. This exact weakness is what motivated the 2013-2015 Password Hashing Competition and its winner, Argon2. Argon2 adds deliberate memory-hardness on top of iteration count, closing the GPU/ASIC advantage far more effectively. PBKDF2 remains acceptable, and is still mandated in some compliance standards and legacy systems. But Argon2id is the modern recommendation for new password-hashing systems where it’s available.
FAQ
Is PBKDF2 still safe to use?
With a sufficiently high iteration count (hundreds of thousands, per current OWASP guidance) it remains an acceptable choice, particularly where compliance requirements mandate it or where Argon2 isn’t available. For new systems without such constraints, Argon2id is the stronger modern recommendation.
Why does PBKDF2 XOR all the intermediate HMAC outputs together instead of just using the last one?
It’s a defense-in-depth design choice from the original specification. Combining every intermediate value means an attacker gains nothing from guessing or shortcutting any single iteration in the middle of the chain. The full sequential chain is always required.
What’s the difference between PBKDF2 and bcrypt?
Both are iterated, deliberately slow password-hashing functions from a similar era. But bcrypt is built around a modified Blowfish key schedule rather than HMAC. It also has somewhat better (though still limited compared to Argon2) resistance to GPU-based cracking, due to its internal memory access patterns.
How is PBKDF2 different from HKDF?
HKDF is designed to expand an already-strong secret (like a Diffie-Hellman shared value) into multiple derived keys quickly. PBKDF2 is designed for the opposite scenario: deliberately slowing down derivation from a weak, guessable secret like a human password. Using HKDF for password hashing (skipping the deliberate slowness) would be insecure.
Why is a higher iteration count more secure?
Each additional iteration adds a fixed, unavoidable amount of computation to both legitimate derivation and any brute-force attempt. The legitimate cost (one login) stays negligible even at high counts. The attacker’s cost, however, multiplies across every single password guess they try. That’s why increasing the iteration count directly increases the total computational cost of any brute-force attack.
References
-
RFC 2898. “PKCS #5: Password-Based Cryptography Specification Version 2.0.” IETF, 2000. Available at: https://datatracker.ietf.org/doc/html/rfc2898
-
RFC 8018. “PKCS #5: Password-Based Cryptography Specification Version 2.1.” IETF, 2017 (updated specification).
-
OWASP. “Password Storage Cheat Sheet.” Available at: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
-
Wikipedia. “PBKDF2.” Available at: https://en.wikipedia.org/wiki/PBKDF2