Password Hashing with PBKDF2
Store passwords with salted, slow PBKDF2 hashes using only the JDK, verify them safely and raise the work factor over time.
byte[] salt = new byte[16];
new SecureRandom().nextBytes(salt);
PBEKeySpec spec = new PBEKeySpec(password, salt, 600_000, 256);
byte[] hash = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
.generateSecret(spec).getEncoded();Passwords are short and guessable, so the threat is not reversing a hash but guessing: an attacker with your users table hashes millions of likely passwords and looks for matches. Password hashing makes each guess slow and makes every user's hash unique. PBKDF2 does that with nothing but the JDK, and it is accepted by standards such as NIST SP 800-132 and the OWASP guidance.
The three ingredients
- A salt. 16 random bytes per password, stored next to the hash. Two users with the same password get different hashes, and precomputed tables become useless.
- An iteration count. PBKDF2 runs HMAC thousands of times. OWASP currently recommends 600,000 iterations for PBKDF2-HMAC-SHA256. Each login then costs a fraction of a second of CPU, which users never notice and attackers pay for every guess.
- A key length. 256 bits matches SHA-256's output.
PBEKeySpec spec = new PBEKeySpec(password, salt, 600_000, 256);
byte[] hash = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
.generateSecret(spec).getEncoded();
spec.clearPassword();
Store the algorithm, the iteration count, the salt and the hash together in one string, for example pbkdf2-sha256$600000$<salt>$<hash>. Verifying reads those values back, hashes the login attempt with the same salt and count, and compares with MessageDigest.isEqual. Because the count is stored per user, you can raise it later: when a login succeeds against an old count, hash the password again with the new one and save it.
Why char[]?
PBEKeySpec takes a char[] so the password can be wiped after use with clearPassword() or Arrays.fill. A String cannot be cleared and lingers in memory until collected. In a web app the password usually arrives as a String anyway, so this is a small extra, not a guarantee.
Mistakes people make
- Plain or salted SHA-256. Fast hashes fall to brute force however much salt you add.
- One salt for every user. A shared salt lets an attacker crack the whole table in one pass.
- Hard-coding the iteration count in the verify path. Read it from the stored value, or raising it breaks every existing login.
- Unlimited password length. Cap input at a sensible size, such as 128 characters, and reject longer values with a 400 before hashing. Some password hashing implementations slow down sharply on very long input, and bcrypt silently ignores everything after 72 bytes.
Argon2id and bcrypt are the other common choices, but they need a library. In a Spring application, Spring Security's password encoders wrap these algorithms and handle the storage format for you.
Example
When to use it
- Hash a new user's password at sign-up and store the algorithm, iteration count, salt and hash in one column.
- Verify a login by re-deriving the hash with the stored salt and count, then compare in constant time.
- Upgrade old hashes to a higher iteration count transparently on each user's next successful login.
More examples
Why every user needs their own salt
Without a salt, identical passwords give identical hashes, so cracking one cracks them all and a precomputed table works. A random salt per user forces the attacker to attack each hash separately.
static final SecureRandom RANDOM = new SecureRandom();
String pbkdf2Hex(String password, byte[] salt) throws GeneralSecurityException {
PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), salt, 600_000, 256);
return HexFormat.of().formatHex(
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded());
}
String sha256Hex(String password) throws GeneralSecurityException {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(password.getBytes(StandardCharsets.UTF_8)));
}
void main() throws Exception {
String password = "summer2026"; // two users picked the same weak password
IO.println("unsalted SHA-256 equal: " + sha256Hex(password).equals(sha256Hex(password)));
byte[] saltA = new byte[16];
byte[] saltB = new byte[16];
RANDOM.nextBytes(saltA);
RANDOM.nextBytes(saltB);
IO.println("salted PBKDF2 equal: " + pbkdf2Hex(password, saltA).equals(pbkdf2Hex(password, saltB)));
}
// Output:
// unsalted SHA-256 equal: true
// salted PBKDF2 equal: falseRehashing on login when the count is too low
A successful login is the only moment the plain password is available, so it is the moment to upgrade the stored hash. Users who never log in again keep the old count until you force a reset.
static final int CURRENT = 600_000;
static final SecureRandom RANDOM = new SecureRandom();
record Stored(int iterations, byte[] salt, byte[] hash) {}
Stored create(char[] password, int iterations) throws GeneralSecurityException {
byte[] salt = new byte[16];
RANDOM.nextBytes(salt);
return new Stored(iterations, salt, derive(password, salt, iterations));
}
byte[] derive(char[] password, byte[] salt, int iterations) throws GeneralSecurityException {
PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, 256);
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
}
void main() throws Exception {
char[] password = "tr0ub4dor&3".toCharArray();
Stored old = create(password, 100_000); // saved years ago
IO.println("stored with " + old.iterations() + " iterations");
boolean ok = MessageDigest.isEqual(derive(password, old.salt(), old.iterations()), old.hash());
IO.println("login ok: " + ok);
if (ok && old.iterations() < CURRENT) {
Stored upgraded = create(password, CURRENT); // fresh salt, new count
IO.println("rehashed with " + upgraded.iterations() + " iterations");
}
Arrays.fill(password, '\0');
}
// Output:
// stored with 100000 iterations
// login ok: true
// rehashed with 600000 iterationsWiping the password after use
PBEKeySpec copies the array, so both the caller's array and the spec's copy need clearing. After clearPassword, the spec refuses to hand the password out again.
void main() throws Exception {
char[] password = "hunter2-but-longer".toCharArray();
byte[] salt = new byte[16];
new SecureRandom().nextBytes(salt);
PBEKeySpec spec = new PBEKeySpec(password, salt, 600_000, 256);
Arrays.fill(password, '*'); // the spec keeps its own copy
byte[] hash = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
spec.clearPassword(); // wipe the copy inside the spec
IO.println("hash bytes: " + hash.length);
IO.println("caller's array: " + new String(password));
try {
spec.getPassword();
} catch (IllegalStateException e) {
IO.println("spec after clear: " + e.getMessage());
}
}
// Output:
// hash bytes: 32
// caller's array: ******************
// spec after clear: password has been cleared
Discussion