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.

Syntaxbyte[] 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · java
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:    false

Rehashing 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.

Example · java
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 iterations

Wiping 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.

Example · java
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

  • Be the first to comment on this lesson.