Storing Passwords Safely in Java

Naveen AnamaneniUpdated · 9 min read

A system that lets users log in must be able to check a password, but it should never be able to read one. Databases get stolen. When that happens, the damage depends entirely on how the passwords were stored: as readable text, every account is lost at once; as fast, unsalted hashes, most are cracked within hours; as salted, deliberately slow hashes, an attacker must spend real time on each individual password. The rule is: store a salted hash produced by a slow function designed for passwords, and compare hashes, never passwords. In this lesson, you will learn why the simpler approaches fail, what salting and slowness achieve, and build a working register and login with the JDK's PBKDF2.

Ways to Store a Password, from Worst to Best

MethodWhat a thief getsVerdict
Plain textEvery password, immediatelyNever
EncryptionEvery password, once the key is found, and the key is usually nearbyNo: passwords don't need to be read back
A fast hash (MD5, SHA-256)Most passwords, by guessing billions per secondNo
A fast hash with a saltStops precomputed tables, but each guess is still very fastNot enough
A slow, salted password hashHas to spend significant time on every guess, for every userYes

Why a Plain Hash Fails

Let's see an example of what goes wrong with unsalted SHA-256:

Weak.java▶ Try it
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
 
public class Weak {
    static String sha256(String text) throws Exception {
        return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
            .digest(text.getBytes(StandardCharsets.UTF_8)));
    }
 
    public static void main(String[] args) throws Exception {
        // Plain SHA-256, no salt: what NOT to do.
        String navika = sha256("password123");
        String advitha = sha256("password123");
        System.out.println(navika.substring(0, 24) + "...");
        System.out.println("Two users, same password, same hash? "
            + navika.equals(advitha));
 
        // How fast can an attacker try guesses?
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        long start = System.nanoTime();
        int guesses = 2_000_000;
        for (int i = 0; i < guesses; i++) {
            md.digest(("guess" + i).getBytes(StandardCharsets.UTF_8));
        }
        long ms = (System.nanoTime() - start) / 1_000_000;
        System.out.println(guesses + " guesses took " + ms + " ms");
    }
}
Output
ef92b778bafe771e89245b89...
Two users, same password, same hash? true
2000000 guesses took 368 ms

Explanation: Two users with the same password get the same hash, so cracking one cracks both, and common passwords can be looked up in precomputed tables. And SHA-256 is fast: this laptop tried two million guesses in about a tenth of a second on one thread (your time will differ). Graphics cards manage billions per second. Speed is good for checking files and bad for protecting passwords.

The Two Defenses

DefenseWhat it isWhat it stops
SaltA random value, different for every user, mixed into the hash and stored beside itIdentical passwords no longer give identical hashes; precomputed tables are useless; each user must be attacked separately
Slowness (work factor)The function repeats its work many thousands of timesEach guess costs real time, so trying billions becomes impractical

The salt is not secret. Its job is to be unique.

Password Hashing Functions

FunctionIn the JDK?Notes
PBKDF2Yes (SecretKeyFactory)Widely accepted; tune the iteration count
bcryptNo (library)Long-established and common
scryptNo (library)Also uses a lot of memory, which hinders graphics-card attacks
Argon2idNo (library)The winner of the Password Hashing Competition; the first choice for new systems when a library is acceptable

Spring Security and Bouncy Castle provide bcrypt, scrypt and Argon2. This lesson uses PBKDF2 because it needs nothing but the JDK.

Checkpoint: Register and Log In

Passwords.java▶ Try it
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
 
public class Passwords {
    static final String ALGORITHM = "PBKDF2WithHmacSHA256";
    static final int ITERATIONS = 600_000;
    static final int SALT_BYTES = 16;
    static final int KEY_BITS = 256;
    static final SecureRandom RANDOM = new SecureRandom();
 
    // The "database": user name -> stored password record.
    static final Map<String, String> USERS = new HashMap<>();
 
    static byte[] derive(char[] password, byte[] salt, int iterations)
            throws Exception {
        PBEKeySpec spec =
            new PBEKeySpec(password, salt, iterations, KEY_BITS);
        try {
            return SecretKeyFactory.getInstance(ALGORITHM)
                .generateSecret(spec).getEncoded();
        } finally {
            spec.clearPassword();
        }
    }
 
    // Returns "iterations:salt:hash", everything needed to check later.
    static String hash(char[] password) throws Exception {
        byte[] salt = new byte[SALT_BYTES];
        RANDOM.nextBytes(salt);
        byte[] key = derive(password, salt, ITERATIONS);
        Base64.Encoder b64 = Base64.getEncoder();
        return ITERATIONS + ":" + b64.encodeToString(salt) + ":"
            + b64.encodeToString(key);
    }
 
    static boolean matches(char[] password, String stored) throws Exception {
        String[] parts = stored.split(":");
        int iterations = Integer.parseInt(parts[0]);
        byte[] salt = Base64.getDecoder().decode(parts[1]);
        byte[] expected = Base64.getDecoder().decode(parts[2]);
        byte[] actual = derive(password, salt, iterations);
        return MessageDigest.isEqual(expected, actual);
    }
 
    static void register(String user, String password) throws Exception {
        USERS.put(user, hash(password.toCharArray()));
    }
 
    static boolean login(String user, String password) throws Exception {
        String stored = USERS.get(user);
        return stored != null && matches(password.toCharArray(), stored);
    }
 
    public static void main(String[] args) throws Exception {
        register("navika", "Correct-Horse-91");
        register("srihitha", "Correct-Horse-91");
 
        String record = USERS.get("navika");
        String[] parts = record.split(":");
        System.out.println("Stored record has " + parts.length
            + " parts: iterations=" + parts[0] + ", salt of "
            + Base64.getDecoder().decode(parts[1]).length
            + " bytes, hash of "
            + Base64.getDecoder().decode(parts[2]).length + " bytes");
        System.out.println("Record contains the password? "
            + record.contains("Correct-Horse-91"));
        System.out.println("Same password, same record for both users? "
            + record.equals(USERS.get("srihitha")));
 
        System.out.println("Login, right password: "
            + login("navika", "Correct-Horse-91"));
        System.out.println("Login, wrong password: "
            + login("navika", "correct-horse-91"));
        System.out.println("Login, unknown user:   "
            + login("advitha", "Correct-Horse-91"));
    }
}
Output
Stored record has 3 parts: iterations=600000, salt of 16 bytes, hash of 32 bytes
Record contains the password? false
Same password, same record for both users? false
Login, right password: true
Login, wrong password: false
Login, unknown user:   false

Explanation:

  1. derive runs PBKDF2 with HMAC-SHA256: the password, the salt, an iteration count and the output size go in; 32 bytes come out. clearPassword() wipes the password from the spec object afterward.
  2. hash creates a fresh 16-byte salt with SecureRandom for every password and returns one text record: iterations:salt:hash. Storing the iteration count with the hash lets you raise it later without breaking old records.
  3. matches reads the salt and the iteration count from the stored record, derives a hash from the password just typed, and compares the two with MessageDigest.isEqual, which takes the same time whether the values differ in the first byte or the last.
  4. The record doesn't contain the password, and nothing can turn it back into one.
  5. Two users with the same password have different records, because their salts differ.
  6. Login succeeds only with the exact password; passwords are case-sensitive.

The iteration count of 600,000 follows current OWASP guidance for PBKDF2 with HMAC-SHA256. The right number rises as computers get faster.

Good Practice Around the Hash

PracticeWhy
Read passwords into a char[] and clear it after useA String stays in memory until garbage collected and can't be wiped
Give the same answer and take similar time for "unknown user" and "wrong password"Otherwise attackers learn which user names exist
Limit login attemptsSlows online guessing
Re-hash on login when your settings have been strengthenedOld records are upgraded gradually
Never log passwords, and never email themLogs and mailboxes are read by many people
Offer multi-factor authenticationA stolen password alone isn't enough

In the example, login for an unknown user returns at once without doing the slow hash. A production system would do a dummy hash there, so that the response time doesn't reveal whether the user exists.

Common Mistakes

Storing plain text "for now". Stolen databases are never an event you planned for.

MD5 or SHA-256, with or without a salt. Too fast.

One salt for the whole application. Identical passwords then still share a hash. Use a new salt per password.

Encrypting passwords so they can be "recovered". A system should reset a password, never reveal it.

Comparing hashes with equals or Arrays.equals. Use MessageDigest.isEqual.

A low iteration count left unchanged for years. Review it periodically.

Interview Corner

How should passwords be stored?

Never in plain text and never encrypted. Store the output of a slow, salted password hashing function: Argon2id, scrypt, bcrypt or PBKDF2 with a high iteration count. Generate a unique random salt per password with SecureRandom and store the salt and the work-factor parameters alongside the hash. To verify, recompute the hash from the submitted password using the stored salt and parameters and compare in constant time with MessageDigest.isEqual. General-purpose hashes such as MD5 or SHA-256 are unsuitable because they are fast.

What is a salt and why is it used?

A salt is a random value, unique per password, combined with the password before hashing and stored with the result. It ensures that identical passwords produce different hashes and defeats precomputed (rainbow) tables, forcing an attacker to attack each password individually. A salt is not secret. It doesn't make a single guess slower; that is the job of the slow hashing function.

Why is char[] preferred over String for passwords?

Strings are immutable and may remain in memory, and in heap dumps, until garbage collected; they can't be erased. A char[] can be overwritten as soon as the password has been used, shortening the time the secret is exposed. APIs such as PBEKeySpec and Console.readPassword() use char[] for this reason.

Practice

  1. Easy: Register a third user and print how many different records exist for three users who all chose the same password.
  2. Harder: Add needsRehash(String stored) that returns true when a record's iteration count is lower than the current ITERATIONS, and re-hash such passwords after a successful login.

Quick check: Storing passwords

4 questions
1What should be stored for each user's password?
2What is the purpose of a salt?
3Why is SHA-256 alone unsuitable for passwords?
4How should two hashes be compared when verifying?

Key Takeaways

Was this tutorial helpful?

Lesson 513 of 578