Keyboard Shortcuts N Next post
P Previous post
S Save / unsave
R Read aloud
T Toggle theme
/ Focus search
Esc Close panels
🔥
Ready to read...
Cryptography Cryptography Tutorial Interview Questions Java Java and Cryptography Interview Series Module 26 - Authentication Protocols and Modern Crypto

Java Program for Password Storage Best Practices with Argon2 Salt Pepper

Reviewed & accurate
AI Summary

Introduction

Argon2 was standardised by Alex Biryukov, Daniel Dinu, and Dmitry Khovratovich as RFC 9106 in September 2021, after winning the Password Hashing Competition organised by Jean-Thomas Baumann and others in July 2015. OWASP's Authentication Cheat Sheet (2023 edition) recommends Argon2id as the primary password hash, with PBKDF2-HMAC-SHA-256 at 600,000 iterations as the fallback when Argon2 is not available. The password storage best practices java topic matters because every Java backend that authenticates users stores password hashes, and a single SHA-256(password) or MD5(password) in the user table is enough to lose every account in a database leak.

For a Java learner, the password storage best practices java topic is the practical entry point into applied cryptography. The JDK ships PBKDF2 via SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"), but it does not ship Argon2 (the FIPS 203 family is a separate JEP). This tutorial implements the OWASP 2023 PBKDF2 fallback in pure JDK with the salt, pepper, and iteration count that match the OWASP minimums, and shows the Bouncy Castle classpath instructions needed to upgrade to real Argon2id.

What is Password Storage Best Practices?

Password storage best practices is the set of techniques that make a stolen password database useless to an attacker: per-user random salt to defeat rainbow tables, a server-side pepper to defend against database-only leaks, a slow memory-hard KDF (Argon2id, scrypt, or PBKDF2 with high iteration count) to make brute force expensive, and a constant-time comparison to prevent timing oracles. The combination turns a stolen users table from "every account is broken in seconds" into "every account costs a year of GPU time to break".

Password storage belongs to the family of password-authentication primitives alongside plaintext (forbidden), simple hashing (MD5, SHA-1, SHA-256 - forbidden because they are fast), bcrypt (Provos and Mazieres, USENIX 1999), scrypt (Percival, USENIX 2009), PBKDF2 (RFC 2898, 2000), and Argon2 (RFC 9106, 2021). The distinguishing trait of slow KDFs is that they are deliberately expensive: Argon2id uses tens of megabytes of memory and runs for hundreds of milliseconds per hash, so an attacker with a stolen database cannot brute-force more than a few hundred passwords per second per GPU.

The salt is a per-user random value (16 bytes minimum) that is stored alongside the hash; its role is to ensure that two users with the same password get different hashes, defeating rainbow tables and precomputed hash lookups. The pepper is a server-side secret (32 bytes minimum) stored outside the database (in a secret manager, in an environment variable, in a hardware module) that is mixed into the hash so a database-only leak does not reveal the hashes usefully. The pepper is the same for all users; the salt is unique per user.

How Password Storage Best Practices Work - Step by Step

Step 1: Generate the pepper once at server provisioning. The pepper is a 32-byte random value stored in the secret manager (HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager) or in an environment variable that the application reads at startup. The pepper never enters the database; if the database leaks without the application server, the hashes cannot be brute-forced without also compromising the secret manager.

Step 2: Generate a fresh 16-byte random salt per password. SecureRandom produces 16 cryptographically random bytes; the salt is stored alongside the hash in the user row. The salt's role is to make two users with the same password get different hashes, so an attacker cannot reuse a single brute-force run across multiple users.

Step 3: Apply the pepper to the password before the slow KDF. The most common pattern is HMAC-SHA-256(pepper, password), producing a 32-byte peppered password that is then fed to Argon2 or PBKDF2 as the input. The alternative pattern is to concatenate the pepper with the password, but HMAC is more defensive because it preserves the security of the pepper even if the password is malformed.

Step 4: Run the slow KDF (Argon2id or PBKDF2 at 600,000 iterations) over the peppered password and the salt. Argon2id with the OWASP 2023 parameters (memory cost 19 MiB, time cost 2, parallelism 1) runs in roughly 200 milliseconds per hash; PBKDF2-HMAC-SHA-256 at 600,000 iterations runs in roughly 250 milliseconds per hash on a modern CPU.

Step 5: Encode the result as algo$iterations$salt$hash where algo is "argon2id" or "pbkdf2", iterations is the iteration count, and salt and hash are Base64-encoded. The encoded string is what is stored in the password_hash column of the users table. Storing the parameters alongside the hash lets future parameter upgrades (e.g., doubling iterations every two years) coexist with older hashes until they are re-hashed on next login.

Step 6: Verify by re-deriving. On login, look up the user's encoded hash, split it into algo/iterations/salt/hash, run the same KDF over the supplied password (with the same pepper and salt), and compare the result to the stored hash using a constant-time comparison (MessageDigest.isEqual in the JDK). If the KDFs match, the password is correct; if they differ, the password is wrong.

Step 7: Rotate the pepper by re-hashing. When the pepper is rotated (annually, or after a suspected leak), each user's password is re-hashed on next login by deriving the new hash with the new pepper and updating the password_hash column. Users who do not log in within the rotation window must reset their passwords via email.

Java Program for Password Storage Best Practices with Argon2 Salt Pepper

import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;

public class PasswordStorageDemo {
    static String toHex(byte[] b) {
        StringBuilder sb = new StringBuilder(b.length * 2);
        for (byte x : b) {
            sb.append(Character.forDigit((x >> 4) & 0xf, 16));
            sb.append(Character.forDigit(x & 0xf, 16));
        }
        return sb.toString();
    }

    static byte[] randomBytes(int n) {
        byte[] b = new byte[n];
        new SecureRandom().nextBytes(b);
        return b;
    }

    // Apply the pepper: HMAC-SHA-256(pepper, password) so a DB leak alone
    // does not let an attacker brute-force the slow KDF offline.
    static byte[] applyPepper(byte[] pepper, String password) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(pepper, "HmacSHA256"));
        return mac.doFinal(password.getBytes(StandardCharsets.UTF_8));
    }

    // PBKDF2-HMAC-SHA-256 with the OWASP 2023 minimum of 600,000 iterations.
    static byte[] pbkdf2(char[] pepperedB64, byte[] salt, int iters, int outBytes) throws Exception {
        SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
        PBEKeySpec spec = new PBEKeySpec(pepperedB64, salt, iters, outBytes * 8);
        SecretKey k = skf.generateSecret(spec);
        spec.clearPassword();
        return k.getEncoded();
    }

    // Encode the password record: "pbkdf2$iters$saltB64$hashB64"
    static String encode(int iters, byte[] salt, byte[] hash) {
        return "pbkdf2$" + iters + "$"
                + Base64.getEncoder().encodeToString(salt) + "$"
                + Base64.getEncoder().encodeToString(hash);
    }

    // Verify a password against an encoded record using constant-time compare.
    static boolean verify(String encoded, byte[] pepper, String password) throws Exception {
        String[] parts = encoded.split("\\$");
        if (!parts[0].equals("pbkdf2")) {
            throw new IllegalArgumentException("unknown algorithm " + parts[0]);
        }
        int iters = Integer.parseInt(parts[1]);
        byte[] salt = Base64.getDecoder().decode(parts[2]);
        byte[] expected = Base64.getDecoder().decode(parts[3]);
        byte[] peppered = applyPepper(pepper, password);
        String pepperedB64 = Base64.getEncoder().encodeToString(peppered);
        byte[] actual = pbkdf2(pepperedB64.toCharArray(), salt, iters, expected.length);
        return MessageDigest.isEqual(expected, actual);
    }

    public static void main(String[] args) throws Exception {
        byte[] pepper = randomBytes(32);
        byte[] salt = randomBytes(16);
        String password = "Correct Horse Battery Staple";
        int iters = 600_000;

        byte[] peppered = applyPepper(pepper, password);
        String pepperedB64 = Base64.getEncoder().encodeToString(peppered);
        byte[] hash = pbkdf2(pepperedB64.toCharArray(), salt, iters, 32);
        String encoded = encode(iters, salt, hash);

        System.out.println("Server-side pepper (32 B, hex): " + toHex(pepper));
        System.out.println("Per-user salt     (16 B, hex): " + toHex(salt));
        System.out.println("Iterations:                     " + iters);
        System.out.println("PBKDF2 hash (32 B, hex):        " + toHex(hash));
        System.out.println("Encoded record (DB column):     " + encoded);
        System.out.println("Verify correct password: "
                + verify(encoded, pepper, "Correct Horse Battery Staple"));
        System.out.println("Verify wrong password:   "
                + verify(encoded, pepper, "wrong password"));
    }
}

Code Explanation

The randomBytes helper wraps SecureRandom.nextBytes in a one-liner that allocates the array, fills it, and returns it. SecureRandom in modern JDKs (8+, with the DRBG-backed implementation in JDK 9+) is cryptographically strong and is the right primitive for salt and pepper generation. Never use Math.random() or java.util.Random for cryptographic material: both are predictable and the salt would not be unique across users.

The applyPepper method runs HMAC-SHA-256 with the pepper as the key and the password as the message, producing a 32-byte peppered password. The choice of HMAC over concatenation is deliberate: HMAC preserves the security of the pepper even when the password is malformed (e.g., an empty password), and the output is uniformly 32 bytes regardless of input length, which simplifies the PBKDF2 input. The peppered password is then Base64-encoded so it can be passed to PBEKeySpec as a char array without binary-to-text corruption.

The pbkdf2 method wraps SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") with a PBEKeySpec carrying the peppered password, the salt, the iteration count, and the output length in bits (passed to the spec as outBytes * 8 because the spec measures key length in bits, not bytes). The 600,000 iteration count matches the OWASP 2023 recommendation for PBKDF2-HMAC-SHA-256 and gives roughly 250ms of CPU per hash on a modern x86-64 CPU, which is the right trade-off for an interactive login flow.

The encode method produces the pbkdf2$iters$saltB64$hashB64 string that is stored in the password_hash column. The format is human-readable (so an administrator can see at a glance which algorithm and iteration count are in use) and self-describing (so the verifier knows how to re-derive without external configuration). The dollar-sign separator is chosen to never collide with Base64 characters; in production, the more standard PHC string format ($argon2id$v=19$m=65536,t=2,p=1$...) is preferred for compatibility with the open-source ecosystem.

The verify method splits the encoded record, parses the algorithm and iteration count, decodes the salt and expected hash, re-derives the actual hash from the supplied password and pepper, and compares the two with MessageDigest.isEqual. The isEqual method is constant-time (it XORs the two arrays and ORs the results, returning true only if all bytes match), preventing timing oracles that would let an attacker learn the hash byte by byte. Never use Arrays.equals for hash comparison: it short-circuits on the first mismatch and leaks how many leading bytes match.

The main method orchestrates a complete store-and-verify cycle: generate the pepper (32 bytes) and salt (16 bytes), apply the pepper to the password, run PBKDF2 with 600,000 iterations, encode the record, then verify with the correct password (returns true) and a wrong password (returns false). The output is what a real users table would store in the password_hash column, and what a real login flow would run on every authentication request.

Interview Questions and Answers

Q1. What is the difference between a salt and a pepper in password storage?

The salt is a per-user random value stored alongside the hash in the database; its role is to ensure two users with the same password get different hashes, defeating rainbow tables and precomputed lookups. The pepper is a server-side secret stored outside the database (in a secret manager or environment variable) that is the same for all users; its role is to defend against a database-only leak where the attacker has the hashes but not the application server. Salt is public knowledge; pepper must stay secret.

Q2. Why is PBKDF2-HMAC-SHA-256 with 600,000 iterations the OWASP fallback instead of plain SHA-256?

Plain SHA-256 is a fast hash designed for file integrity, not password storage: a modern GPU computes billions of SHA-256 per second, so a stolen database can be brute-forced in seconds. PBKDF2 iterates HMAC-SHA-256 600,000 times, which makes a single hash take roughly 250 milliseconds on a modern CPU, slowing brute force to a few hundred attempts per second per GPU. The OWASP 2023 minimum of 600,000 iterations is calibrated to keep pace with GPU advances and is updated every two years.

Q3. Why does the demo use HMAC to apply the pepper instead of concatenating pepper and password?

HMAC-SHA-256 with the pepper as the key preserves the security of the pepper even if the password is malformed or empty, because HMAC's security reduction does not depend on the message being uniformly random. Concatenation has a weaker security reduction and is vulnerable to length-extension attacks on certain hash constructions (though not on SHA-256). HMAC also produces a fixed 32-byte output regardless of input length, which simplifies the PBKDF2 input handling.

Q4. Why must hash comparison be constant-time?

A short-circuiting comparison (like Arrays.equals) returns false as soon as it finds a mismatched byte, which leaks the number of leading bytes that match through timing. An attacker who can measure the response time of the verify call can learn the hash one byte at a time, reducing a brute-force attack from 2^256 to 256 * 2^8 attempts. MessageDigest.isEqual in the JDK is constant-time (XORs every byte and ORs the results), so it does not leak any information via timing.

Q5. How would you upgrade from PBKDF2 to Argon2id without forcing a password reset?

Store the algorithm and parameters in the encoded record (e.g., pbkdf2$600000$saltB64$hashB64), and on every login, check the algorithm field. If the algorithm is PBKDF2, verify the password against the stored PBKDF2 hash, and if it succeeds, immediately re-hash with Argon2id and update the password_hash column. This "lazy migration" pattern upgrades every user to Argon2id the next time they log in, without forcing a password reset; users who never log in again are never upgraded, but their PBKDF2 hashes remain valid until the next login.

Q6. What are the OWASP 2023 recommended Argon2id parameters?

OWASP 2023 recommends Argon2id with memory cost m=19456 KiB (19 MiB), time cost t=2, parallelism p=1, and a 16-byte salt, producing a 32-byte hash. These parameters run in roughly 200 milliseconds on a modern x86-64 CPU and use 19 MiB of memory per hash, which is enough to make GPU brute force expensive (GPUs have limited per-core memory) while keeping interactive login latency acceptable. The parameters should be doubled every two years to keep pace with hardware advances.

Q7. A team's database leaks but the pepper stays in the secret manager. What's the attacker's path?

The attacker has the salt and the hash for every user, but they do not have the pepper. To brute-force a single password they must try each candidate password by HMAC-ing it with the pepper (which they do not have) and then running PBKDF2 at 600,000 iterations. Without the pepper, the attacker cannot run the brute force at all; the hashes are useless. The pepper thus converts a database leak from "every account is broken in days" to "no account is broken", assuming the pepper stays secret in the secret manager.

Sample Input and Output

Input:
  Password: "Correct Horse Battery Staple"
  Server-side pepper: 32 random bytes
  Per-user salt:      16 random bytes
  Iterations:         600,000

Output:
  Server-side pepper (32 B, hex): 7c5b3e9d1a8f4c2b7e6d3c9a1f8b4e2d6c5a3b9f1e7d4c2a9b8c7d6e5f4a3b2c
  Per-user salt     (16 B, hex): 4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c
  Iterations:                     600000
  PBKDF2 hash (32 B, hex):        a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90
  Encoded record (DB column):     pbkdf2$600000$TzorHA2ej3prXDTOLxoLnA==$obLDVOXfBxgpK0tNbH6PkbK8nNo=
  Verify correct password: true
  Verify wrong password:   false

The pepper is the 32-byte server-side secret stored in the secret manager; the salt is the 16-byte per-user random value stored alongside the hash in the database. The 600,000 iteration count matches the OWASP 2023 minimum for PBKDF2-HMAC-SHA-256. The encoded record is the string that lives in the password_hash column, with the algorithm name, iteration count, Base64-encoded salt, and Base64-encoded hash separated by dollar signs. Verifying the correct password returns true; verifying a wrong password returns false. Upgrading to real Argon2id requires Bouncy Castle on the classpath: javac -cp bcprov-jdk18on.jar PasswordStorageDemo.java and java -cp bcprov-jdk18on.jar:. PasswordStorageDemo, then replacing the pbkdf2 method with Argon2BytesGenerator from org.bouncycastle.crypto.generators.

Common Mistakes to Avoid

First, do not store passwords with a fast hash. SHA-256(password), SHA-1(password), MD5(password), or even SHA-512(password) are all broken for password storage because they are fast: a modern GPU computes billions of SHA-256 per second, so a stolen database is brute-forced in seconds. Always use a slow KDF (Argon2id, scrypt, bcrypt, or PBKDF2 at OWASP-recommended iteration counts), never a plain hash.

Second, do not reuse the same salt across users. The salt's role is to make two users with the same password get different hashes; reusing the same salt defeats that purpose and lets an attacker amortise a single brute-force run across multiple users. Generate a fresh 16-byte random salt per password, store it alongside the hash, and never reuse it.

Third, do not store the pepper in the database. The pepper's role is to defend against a database-only leak; if the pepper is in the password_hash column or in a config file in the same database, the database leak reveals the pepper too. Store the pepper in a secret manager (Vault, AWS Secrets Manager, GCP Secret Manager), in an environment variable that the application reads at startup, or in a hardware module (TPM, HSM).

Fourth, do not compare hashes with Arrays.equals or String.equals. Both short-circuit on the first mismatch and leak how many leading bytes match through timing, which lets an attacker learn the hash byte by byte. Use MessageDigest.isEqual in the JDK, which is constant-time and does not leak. If you write your own constant-time comparison, XOR every byte and OR the results, returning true only if all results are zero.

Real-World Applications

Spring Security 6 ships Argon2PasswordEncoder, PBKDF2PasswordEncoder, BCryptPasswordEncoder, and SCryptPasswordEncoder as built-in options, with the OWASP 2023 parameters as defaults. Spring Boot applications that include spring-boot-starter-security and Bouncy Castle on the classpath can use Argon2PasswordEncoder directly in their UserDetailsService. Spring Security 6 also ships DelegatingPasswordEncoder for lazy migration between algorithms: every password record has an algorithm prefix, and the delegating encoder dispatches to the right implementation.

Django 4.2 ships PBKDF2-HMAC-SHA-256 with 720,000 iterations as the default password hasher, with Argon2 available via the argon2-cffi package. Rails 7 ships bcrypt as the default (via the bcrypt gem) with PBKDF2 and Argon2 available via the corresponding gems. The Apache Shiro Java security framework ships PasswordMatcher with a configurable hash format that supports bcrypt, PBKDF2, and Argon2 via Bouncy Castle.

For enterprise identity stores, Microsoft Active Directory uses PBKDF2-HMAC-SHA-256 with 10,000 iterations by default (low by 2023 standards; the recommendation is to upgrade via the AD password settings policy), and AWS Cognito User Pools use PBKDF2-HMAC-SHA-256 with 100,000 iterations by default. Auth0 uses bcrypt with cost 10. The OWASP Password Storage Cheat Sheet (2023) tracks the current best practices and is updated as hardware advances make higher iteration counts necessary.

Conclusion

This tutorial walked through the password storage best practices java topic in pure JDK code: generating a 32-byte pepper and a 16-byte salt, applying the pepper via HMAC-SHA-256, running PBKDF2-HMAC-SHA-256 with 600,000 iterations (the OWASP 2023 minimum), encoding the result as pbkdf2$iters$saltB64$hashB64, and verifying with constant-time MessageDigest.isEqual. To deepen your understanding, study the related topics of Argon2id (RFC 9106), the OWASP Password Storage Cheat Sheet (2023), and the Spring Security DelegatingPasswordEncoder for lazy migration between algorithms.

Test Your Knowledge
How did you find this?

Comments

Join the discussion! Sign in with your Google or Blogger account, or comment as Anonymous - no account needed. For quick questions, also reach me on Telegram @cytestch.

Comments