Browse Source

Add password4j implementation of PasswordEncoder

Rob Winch 2 weeks ago
parent
commit
5ca18a3b9c
30 changed files with 2515 additions and 1 deletions
  1. 1 0
      crypto/spring-security-crypto.gradle
  2. 74 0
      crypto/src/main/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoder.java
  3. 159 0
      crypto/src/main/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoder.java
  4. 72 0
      crypto/src/main/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoder.java
  5. 78 0
      crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java
  6. 157 0
      crypto/src/main/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoder.java
  7. 74 0
      crypto/src/main/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoder.java
  8. 20 0
      crypto/src/main/java/org/springframework/security/crypto/password4j/package-info.java
  9. 245 0
      crypto/src/test/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoderTests.java
  10. 170 0
      crypto/src/test/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoderTests.java
  11. 217 0
      crypto/src/test/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoderTests.java
  12. 105 0
      crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java
  13. 153 0
      crypto/src/test/java/org/springframework/security/crypto/password4j/PasswordCompatibilityTests.java
  14. 167 0
      crypto/src/test/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoderTests.java
  15. 248 0
      crypto/src/test/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoderTests.java
  16. 1 1
      dependencies/spring-security-dependencies.gradle
  17. 109 0
      docs/modules/ROOT/pages/features/authentication/password-storage.adoc
  18. 9 0
      docs/modules/ROOT/pages/whats-new.adoc
  19. 1 0
      docs/spring-security-docs.gradle
  20. 53 0
      docs/src/test/java/org/springframework/security/docs/features/authentication/password4jargon2/Argon2UsageTests.java
  21. 52 0
      docs/src/test/java/org/springframework/security/docs/features/authentication/password4jballooning/BallooningHashingUsageTests.java
  22. 52 0
      docs/src/test/java/org/springframework/security/docs/features/authentication/password4jbcrypt/BcryptUsageTests.java
  23. 52 0
      docs/src/test/java/org/springframework/security/docs/features/authentication/password4jpbkdf2/Pbkdf2UsageTests.java
  24. 51 0
      docs/src/test/java/org/springframework/security/docs/features/authentication/password4jscrypt/ScryptUsageTests.java
  25. 51 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jargon2/Argon2UsageTests.kt
  26. 47 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jballooning/BallooningHashingUsageTests.kt
  27. 32 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jbcrypt/BcryptUsageTests.kt
  28. 32 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jpbkdf2/Pbkdf2UsageTests.kt
  29. 31 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jscrypt/ScryptUsageTests.kt
  30. 2 0
      gradle/libs.versions.toml

+ 1 - 0
crypto/spring-security-crypto.gradle

@@ -8,6 +8,7 @@ dependencies {
 	management platform(project(":spring-security-dependencies"))
 	optional 'org.springframework:spring-core'
 	optional 'org.bouncycastle:bcpkix-jdk18on'
+	optional libs.com.password4j.password4j
 
 	testImplementation "org.assertj:assertj-core"
 	testImplementation "org.junit.jupiter:junit-jupiter-api"

+ 74 - 0
crypto/src/main/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoder.java

@@ -0,0 +1,74 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.crypto.password4j;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.Argon2Function;
+
+/**
+ * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder}
+ * that uses the Password4j library with Argon2 hashing algorithm.
+ *
+ * <p>
+ * Argon2 is the winner of the Password Hashing Competition (2015) and is recommended for
+ * new applications. It provides excellent resistance against GPU-based attacks and
+ * includes built-in salt generation. This implementation leverages Password4j's Argon2
+ * support which properly includes the salt in the output hash.
+ * </p>
+ *
+ * <p>
+ * This implementation is thread-safe and can be shared across multiple threads.
+ * </p>
+ *
+ * <p>
+ * <strong>Usage Examples:</strong>
+ * </p>
+ * <pre>{@code
+ * // Using default Argon2 settings (recommended)
+ * PasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+ *
+ * // Using custom Argon2 configuration
+ * PasswordEncoder customEncoder = new Argon2Password4jPasswordEncoder(
+ *     Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID));
+ * }</pre>
+ *
+ * @author Mehrdad Bozorgmehr
+ * @since 7.0
+ * @see Argon2Function
+ * @see AlgorithmFinder#getArgon2Instance()
+ */
+public class Argon2Password4jPasswordEncoder extends Password4jPasswordEncoder {
+
+	/**
+	 * Constructs an Argon2 password encoder using the default Argon2 configuration from
+	 * Password4j's AlgorithmFinder.
+	 */
+	public Argon2Password4jPasswordEncoder() {
+		super(AlgorithmFinder.getArgon2Instance());
+	}
+
+	/**
+	 * Constructs an Argon2 password encoder with a custom Argon2 function.
+	 * @param argon2Function the Argon2 function to use for encoding passwords, must not
+	 * be null
+	 * @throws IllegalArgumentException if argon2Function is null
+	 */
+	public Argon2Password4jPasswordEncoder(Argon2Function argon2Function) {
+		super(argon2Function);
+	}
+
+}

+ 159 - 0
crypto/src/main/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoder.java

@@ -0,0 +1,159 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.crypto.password4j;
+
+import java.security.SecureRandom;
+import java.util.Base64;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.BalloonHashingFunction;
+import com.password4j.Hash;
+import com.password4j.Password;
+
+import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
+import org.springframework.util.Assert;
+
+/**
+ * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder}
+ * that uses the Password4j library with Balloon hashing algorithm.
+ *
+ * <p>
+ * Balloon hashing is a memory-hard password hashing algorithm designed to be resistant to
+ * both time-memory trade-off attacks and side-channel attacks. This implementation
+ * handles the salt management explicitly since Password4j's Balloon hashing
+ * implementation does not include the salt in the output hash.
+ * </p>
+ *
+ * <p>
+ * The encoded password format is: {salt}:{hash} where both salt and hash are Base64
+ * encoded.
+ * </p>
+ *
+ * <p>
+ * This implementation is thread-safe and can be shared across multiple threads.
+ * </p>
+ *
+ * <p>
+ * <strong>Usage Examples:</strong>
+ * </p>
+ * <pre>{@code
+ * // Using default Balloon hashing settings (recommended)
+ * PasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+ *
+ * // Using custom Balloon hashing function
+ * PasswordEncoder customEncoder = new BalloonHashingPassword4jPasswordEncoder(
+ *     BalloonHashingFunction.getInstance(1024, 3, 4, "SHA-256"));
+ * }</pre>
+ *
+ * @author Mehrdad Bozorgmehr
+ * @since 7.0
+ * @see BalloonHashingFunction
+ * @see AlgorithmFinder#getBalloonHashingInstance()
+ */
+public class BalloonHashingPassword4jPasswordEncoder extends AbstractValidatingPasswordEncoder {
+
+	private static final String DELIMITER = ":";
+
+	private static final int DEFAULT_SALT_LENGTH = 32;
+
+	private final BalloonHashingFunction balloonHashingFunction;
+
+	private final SecureRandom secureRandom;
+
+	private final int saltLength;
+
+	/**
+	 * Constructs a Balloon hashing password encoder using the default Balloon hashing
+	 * configuration from Password4j's AlgorithmFinder.
+	 */
+	public BalloonHashingPassword4jPasswordEncoder() {
+		this(AlgorithmFinder.getBalloonHashingInstance());
+	}
+
+	/**
+	 * Constructs a Balloon hashing password encoder with a custom Balloon hashing
+	 * function.
+	 * @param balloonHashingFunction the Balloon hashing function to use for encoding
+	 * passwords, must not be null
+	 * @throws IllegalArgumentException if balloonHashingFunction is null
+	 */
+	public BalloonHashingPassword4jPasswordEncoder(BalloonHashingFunction balloonHashingFunction) {
+		this(balloonHashingFunction, DEFAULT_SALT_LENGTH);
+	}
+
+	/**
+	 * Constructs a Balloon hashing password encoder with a custom Balloon hashing
+	 * function and salt length.
+	 * @param balloonHashingFunction the Balloon hashing function to use for encoding
+	 * passwords, must not be null
+	 * @param saltLength the length of the salt in bytes, must be positive
+	 * @throws IllegalArgumentException if balloonHashingFunction is null or saltLength is
+	 * not positive
+	 */
+	public BalloonHashingPassword4jPasswordEncoder(BalloonHashingFunction balloonHashingFunction, int saltLength) {
+		Assert.notNull(balloonHashingFunction, "balloonHashingFunction cannot be null");
+		Assert.isTrue(saltLength > 0, "saltLength must be positive");
+		this.balloonHashingFunction = balloonHashingFunction;
+		this.saltLength = saltLength;
+		this.secureRandom = new SecureRandom();
+	}
+
+	@Override
+	protected String encodeNonNullPassword(String rawPassword) {
+		byte[] salt = new byte[this.saltLength];
+		this.secureRandom.nextBytes(salt);
+
+		Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.balloonHashingFunction);
+		String encodedSalt = Base64.getEncoder().encodeToString(salt);
+		String encodedHash = hash.getResult();
+
+		return encodedSalt + DELIMITER + encodedHash;
+	}
+
+	@Override
+	protected boolean matchesNonNull(String rawPassword, String encodedPassword) {
+		if (!encodedPassword.contains(DELIMITER)) {
+			return false;
+		}
+
+		String[] parts = encodedPassword.split(DELIMITER, 2);
+		if (parts.length != 2) {
+			return false;
+		}
+
+		try {
+			byte[] salt = Base64.getDecoder().decode(parts[0]);
+			String expectedHash = parts[1];
+
+			Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.balloonHashingFunction);
+			return expectedHash.equals(hash.getResult());
+		}
+		catch (IllegalArgumentException ex) {
+			// Invalid Base64 encoding
+			return false;
+		}
+	}
+
+	@Override
+	protected boolean upgradeEncodingNonNull(String encodedPassword) {
+		// For now, we'll return false to maintain existing behavior
+		// This could be enhanced in the future to check if the encoding parameters
+		// match the current configuration
+		return false;
+	}
+
+}

+ 72 - 0
crypto/src/main/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoder.java

@@ -0,0 +1,72 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.crypto.password4j;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.BcryptFunction;
+
+/**
+ * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder}
+ * that uses the Password4j library with BCrypt hashing algorithm.
+ *
+ * <p>
+ * BCrypt is a well-established password hashing algorithm that includes built-in salt
+ * generation and is resistant to rainbow table attacks. This implementation leverages
+ * Password4j's BCrypt support which properly includes the salt in the output hash.
+ * </p>
+ *
+ * <p>
+ * This implementation is thread-safe and can be shared across multiple threads.
+ * </p>
+ *
+ * <p>
+ * <strong>Usage Examples:</strong>
+ * </p>
+ * <pre>{@code
+ * // Using default BCrypt settings (recommended)
+ * PasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+ *
+ * // Using custom round count
+ * PasswordEncoder customEncoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(12));
+ * }</pre>
+ *
+ * @author Mehrdad Bozorgmehr
+ * @since 7.0
+ * @see BcryptFunction
+ * @see AlgorithmFinder#getBcryptInstance()
+ */
+public class BcryptPassword4jPasswordEncoder extends Password4jPasswordEncoder {
+
+	/**
+	 * Constructs a BCrypt password encoder using the default BCrypt configuration from
+	 * Password4j's AlgorithmFinder.
+	 */
+	public BcryptPassword4jPasswordEncoder() {
+		super(AlgorithmFinder.getBcryptInstance());
+	}
+
+	/**
+	 * Constructs a BCrypt password encoder with a custom BCrypt function.
+	 * @param bcryptFunction the BCrypt function to use for encoding passwords, must not
+	 * be null
+	 * @throws IllegalArgumentException if bcryptFunction is null
+	 */
+	public BcryptPassword4jPasswordEncoder(BcryptFunction bcryptFunction) {
+		super(bcryptFunction);
+	}
+
+}

+ 78 - 0
crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java

@@ -0,0 +1,78 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.crypto.password4j;
+
+import com.password4j.Hash;
+import com.password4j.HashingFunction;
+import com.password4j.Password;
+
+import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
+import org.springframework.util.Assert;
+
+/**
+ * Abstract base class for Password4j-based password encoders. This class provides the
+ * common functionality for password encoding and verification using the Password4j
+ * library.
+ *
+ * <p>
+ * This class is package-private and should not be used directly. Instead, use the
+ * specific public subclasses that support verified hashing algorithms such as BCrypt,
+ * Argon2, and SCrypt implementations.
+ * </p>
+ *
+ * <p>
+ * This implementation is thread-safe and can be shared across multiple threads.
+ * </p>
+ *
+ * @author Mehrdad Bozorgmehr
+ * @since 7.0
+ */
+abstract class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder {
+
+	private final HashingFunction hashingFunction;
+
+	/**
+	 * Constructs a Password4j password encoder with the specified hashing function. This
+	 * constructor is package-private and intended for use by subclasses only.
+	 * @param hashingFunction the hashing function to use for encoding passwords, must not
+	 * be null
+	 * @throws IllegalArgumentException if hashingFunction is null
+	 */
+	Password4jPasswordEncoder(HashingFunction hashingFunction) {
+		Assert.notNull(hashingFunction, "hashingFunction cannot be null");
+		this.hashingFunction = hashingFunction;
+	}
+
+	@Override
+	protected String encodeNonNullPassword(String rawPassword) {
+		Hash hash = Password.hash(rawPassword).with(this.hashingFunction);
+		return hash.getResult();
+	}
+
+	@Override
+	protected boolean matchesNonNull(String rawPassword, String encodedPassword) {
+		return Password.check(rawPassword, encodedPassword).with(this.hashingFunction);
+	}
+
+	@Override
+	protected boolean upgradeEncodingNonNull(String encodedPassword) {
+		// Password4j handles upgrade detection internally for most algorithms
+		// For now, we'll return false to maintain existing behavior
+		return false;
+	}
+
+}

+ 157 - 0
crypto/src/main/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoder.java

@@ -0,0 +1,157 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.crypto.password4j;
+
+import java.security.SecureRandom;
+import java.util.Base64;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.Hash;
+import com.password4j.PBKDF2Function;
+import com.password4j.Password;
+
+import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
+import org.springframework.util.Assert;
+
+/**
+ * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder}
+ * that uses the Password4j library with PBKDF2 hashing algorithm.
+ *
+ * <p>
+ * PBKDF2 is a key derivation function designed to be computationally expensive to thwart
+ * dictionary and brute force attacks. This implementation handles the salt management
+ * explicitly since Password4j's PBKDF2 implementation does not include the salt in the
+ * output hash.
+ * </p>
+ *
+ * <p>
+ * The encoded password format is: {salt}:{hash} where both salt and hash are Base64
+ * encoded.
+ * </p>
+ *
+ * <p>
+ * This implementation is thread-safe and can be shared across multiple threads.
+ * </p>
+ *
+ * <p>
+ * <strong>Usage Examples:</strong>
+ * </p>
+ * <pre>{@code
+ * // Using default PBKDF2 settings (recommended)
+ * PasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+ *
+ * // Using custom PBKDF2 function
+ * PasswordEncoder customEncoder = new Pbkdf2Password4jPasswordEncoder(
+ *     PBKDF2Function.getInstance(Algorithm.HMAC_SHA256, 100000, 256));
+ * }</pre>
+ *
+ * @author Mehrdad Bozorgmehr
+ * @since 7.0
+ * @see PBKDF2Function
+ * @see AlgorithmFinder#getPBKDF2Instance()
+ */
+public class Pbkdf2Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder {
+
+	private static final String DELIMITER = ":";
+
+	private static final int DEFAULT_SALT_LENGTH = 32;
+
+	private final PBKDF2Function pbkdf2Function;
+
+	private final SecureRandom secureRandom;
+
+	private final int saltLength;
+
+	/**
+	 * Constructs a PBKDF2 password encoder using the default PBKDF2 configuration from
+	 * Password4j's AlgorithmFinder.
+	 */
+	public Pbkdf2Password4jPasswordEncoder() {
+		this(AlgorithmFinder.getPBKDF2Instance());
+	}
+
+	/**
+	 * Constructs a PBKDF2 password encoder with a custom PBKDF2 function.
+	 * @param pbkdf2Function the PBKDF2 function to use for encoding passwords, must not
+	 * be null
+	 * @throws IllegalArgumentException if pbkdf2Function is null
+	 */
+	public Pbkdf2Password4jPasswordEncoder(PBKDF2Function pbkdf2Function) {
+		this(pbkdf2Function, DEFAULT_SALT_LENGTH);
+	}
+
+	/**
+	 * Constructs a PBKDF2 password encoder with a custom PBKDF2 function and salt length.
+	 * @param pbkdf2Function the PBKDF2 function to use for encoding passwords, must not
+	 * be null
+	 * @param saltLength the length of the salt in bytes, must be positive
+	 * @throws IllegalArgumentException if pbkdf2Function is null or saltLength is not
+	 * positive
+	 */
+	public Pbkdf2Password4jPasswordEncoder(PBKDF2Function pbkdf2Function, int saltLength) {
+		Assert.notNull(pbkdf2Function, "pbkdf2Function cannot be null");
+		Assert.isTrue(saltLength > 0, "saltLength must be positive");
+		this.pbkdf2Function = pbkdf2Function;
+		this.saltLength = saltLength;
+		this.secureRandom = new SecureRandom();
+	}
+
+	@Override
+	protected String encodeNonNullPassword(String rawPassword) {
+		byte[] salt = new byte[this.saltLength];
+		this.secureRandom.nextBytes(salt);
+
+		Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.pbkdf2Function);
+		String encodedSalt = Base64.getEncoder().encodeToString(salt);
+		String encodedHash = hash.getResult();
+
+		return encodedSalt + DELIMITER + encodedHash;
+	}
+
+	@Override
+	protected boolean matchesNonNull(String rawPassword, String encodedPassword) {
+		if (!encodedPassword.contains(DELIMITER)) {
+			return false;
+		}
+
+		String[] parts = encodedPassword.split(DELIMITER, 2);
+		if (parts.length != 2) {
+			return false;
+		}
+
+		try {
+			byte[] salt = Base64.getDecoder().decode(parts[0]);
+			String expectedHash = parts[1];
+
+			Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.pbkdf2Function);
+			return expectedHash.equals(hash.getResult());
+		}
+		catch (IllegalArgumentException ex) {
+			// Invalid Base64 encoding
+			return false;
+		}
+	}
+
+	@Override
+	protected boolean upgradeEncodingNonNull(String encodedPassword) {
+		// For now, we'll return false to maintain existing behavior
+		// This could be enhanced in the future to check if the encoding parameters
+		// match the current configuration
+		return false;
+	}
+
+}

+ 74 - 0
crypto/src/main/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoder.java

@@ -0,0 +1,74 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.crypto.password4j;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.ScryptFunction;
+
+/**
+ * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder}
+ * that uses the Password4j library with SCrypt hashing algorithm.
+ *
+ * <p>
+ * SCrypt is a memory-hard password hashing algorithm designed to be resistant to hardware
+ * brute-force attacks. It includes built-in salt generation and is particularly effective
+ * against ASIC and GPU-based attacks. This implementation leverages Password4j's SCrypt
+ * support which properly includes the salt in the output hash.
+ * </p>
+ *
+ * <p>
+ * This implementation is thread-safe and can be shared across multiple threads.
+ * </p>
+ *
+ * <p>
+ * <strong>Usage Examples:</strong>
+ * </p>
+ * <pre>{@code
+ * // Using default SCrypt settings (recommended)
+ * PasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+ *
+ * // Using custom SCrypt configuration
+ * PasswordEncoder customEncoder = new ScryptPassword4jPasswordEncoder(
+ *     ScryptFunction.getInstance(32768, 8, 1, 32));
+ * }</pre>
+ *
+ * @author Mehrdad Bozorgmehr
+ * @since 7.0
+ * @see ScryptFunction
+ * @see AlgorithmFinder#getScryptInstance()
+ */
+public class ScryptPassword4jPasswordEncoder extends Password4jPasswordEncoder {
+
+	/**
+	 * Constructs an SCrypt password encoder using the default SCrypt configuration from
+	 * Password4j's AlgorithmFinder.
+	 */
+	public ScryptPassword4jPasswordEncoder() {
+		super(AlgorithmFinder.getScryptInstance());
+	}
+
+	/**
+	 * Constructs an SCrypt password encoder with a custom SCrypt function.
+	 * @param scryptFunction the SCrypt function to use for encoding passwords, must not
+	 * be null
+	 * @throws IllegalArgumentException if scryptFunction is null
+	 */
+	public ScryptPassword4jPasswordEncoder(ScryptFunction scryptFunction) {
+		super(scryptFunction);
+	}
+
+}

+ 20 - 0
crypto/src/main/java/org/springframework/security/crypto/password4j/package-info.java

@@ -0,0 +1,20 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@NullMarked
+package org.springframework.security.crypto.password4j;
+
+import org.jspecify.annotations.NullMarked;

+ 245 - 0
crypto/src/test/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoderTests.java

@@ -0,0 +1,245 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.crypto.password4j;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.Argon2Function;
+import com.password4j.types.Argon2;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link Argon2Password4jPasswordEncoder}.
+ *
+ * @author Mehrdad Bozorgmehr
+ */
+class Argon2Password4jPasswordEncoderTests {
+
+	private static final String PASSWORD = "password";
+
+	private static final String LONG_PASSWORD = "a".repeat(1000);
+
+	private static final String SPECIAL_CHARS_PASSWORD = "p@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?";
+
+	private static final String UNICODE_PASSWORD = "пароль密码パスワード🔐";
+
+	@Test
+	void defaultConstructorShouldCreateWorkingEncoder() {
+		Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull();
+		assertThat(encoded).startsWith("$argon2"); // Argon2 hash format
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void constructorWithNullArgon2FunctionShouldThrowException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> new Argon2Password4jPasswordEncoder(null))
+			.withMessage("hashingFunction cannot be null");
+	}
+
+	@Test
+	void constructorWithCustomArgon2FunctionShouldWork() {
+		Argon2Function customFunction = Argon2Function.getInstance(4096, 3, 1, 32, Argon2.ID);
+		Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(customFunction);
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull();
+		assertThat(encoded).startsWith("$argon2id");
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@ParameterizedTest
+	@EnumSource(Argon2.class)
+	void encodingShouldWorkWithDifferentArgon2Types(Argon2 type) {
+		Argon2Function function = Argon2Function.getInstance(4096, 3, 1, 32, type);
+		Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(function);
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull();
+		assertThat(encoded).startsWith("$argon2" + type.name().toLowerCase());
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void encodingShouldGenerateDifferentHashesForSamePassword() {
+		Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+
+		String hash1 = encoder.encode(PASSWORD);
+		String hash2 = encoder.encode(PASSWORD);
+
+		assertThat(hash1).isNotEqualTo(hash2);
+		assertThat(encoder.matches(PASSWORD, hash1)).isTrue();
+		assertThat(encoder.matches(PASSWORD, hash2)).isTrue();
+	}
+
+	@Test
+	void shouldHandleLongPasswords() {
+		Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+
+		String encoded = encoder.encode(LONG_PASSWORD);
+
+		assertThat(encoder.matches(LONG_PASSWORD, encoded)).isTrue();
+		assertThat(encoder.matches("wrong", encoded)).isFalse();
+	}
+
+	@Test
+	void shouldHandleSpecialCharacters() {
+		Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+
+		String encoded = encoder.encode(SPECIAL_CHARS_PASSWORD);
+
+		assertThat(encoder.matches(SPECIAL_CHARS_PASSWORD, encoded)).isTrue();
+		assertThat(encoder.matches("wrong", encoded)).isFalse();
+	}
+
+	@Test
+	void shouldHandleUnicodeCharacters() {
+		Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+
+		String encoded = encoder.encode(UNICODE_PASSWORD);
+
+		assertThat(encoder.matches(UNICODE_PASSWORD, encoded)).isTrue();
+		assertThat(encoder.matches("wrong", encoded)).isFalse();
+	}
+
+	@Test
+	void shouldRejectIncorrectPasswords() {
+		Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoder.matches("wrongpassword", encoded)).isFalse();
+		assertThat(encoder.matches("PASSWORD", encoded)).isFalse(); // Case sensitive
+		assertThat(encoder.matches("password ", encoded)).isFalse(); // Trailing space
+		assertThat(encoder.matches(" password", encoded)).isFalse(); // Leading space
+	}
+
+	@Test
+	void matchesShouldReturnFalseForNullOrEmptyInputs() {
+		Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoder.matches(null, encoded)).isFalse();
+		assertThat(encoder.matches("", encoded)).isFalse();
+		assertThat(encoder.matches(PASSWORD, null)).isFalse();
+		assertThat(encoder.matches(PASSWORD, "")).isFalse();
+		assertThat(encoder.matches(null, null)).isFalse();
+		assertThat(encoder.matches("", "")).isFalse();
+	}
+
+	@Test
+	void encodeNullShouldReturnNull() {
+		Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+
+		assertThat(encoder.encode(null)).isNull();
+	}
+
+	@Test
+	void upgradeEncodingShouldReturnFalse() {
+		Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoder.upgradeEncoding(encoded)).isFalse();
+	}
+
+	@Test
+	void shouldWorkWithAlgorithmFinderDefaults() {
+		Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(
+				AlgorithmFinder.getArgon2Instance());
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull();
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void shouldRejectMalformedHashes() {
+		Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+
+		// For Argon2, Password4j may throw BadParametersException on malformed hashes.
+		// We treat either an exception or a false return as a successful rejection.
+		assertMalformedRejected(encoder, PASSWORD, "invalid_hash");
+		assertMalformedRejected(encoder, PASSWORD, "$argon2id$invalid");
+		assertMalformedRejected(encoder, PASSWORD, "");
+	}
+
+	private void assertMalformedRejected(Argon2Password4jPasswordEncoder encoder, String raw, String malformed) {
+		boolean rejected = false;
+		try {
+			rejected = !encoder.matches(raw, malformed);
+		}
+		catch (RuntimeException ex) {
+			// Accept exception as valid rejection path for malformed input
+			rejected = true;
+		}
+		assertThat(rejected).as("Malformed hash should not validate: " + malformed).isTrue();
+	}
+
+	@Test
+	void shouldHandleEmptyStringPassword() {
+		Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+
+		String encoded = encoder.encode("");
+
+		assertThat(encoded).isNotNull();
+		boolean emptyStringMatches;
+		try {
+			emptyStringMatches = encoder.matches("", encoded);
+		}
+		catch (RuntimeException ex) {
+			emptyStringMatches = false; // treat exception as non-match but still
+										// acceptable behavior
+		}
+
+		if (emptyStringMatches) {
+			assertThat(encoder.matches("", encoded)).isTrue();
+		}
+		else {
+			assertThat(encoded).isNotEmpty();
+		}
+		assertThat(encoder.matches("notEmpty", encoded)).isFalse();
+	}
+
+	@Test
+	void shouldHandleCustomMemoryAndIterationParameters() {
+		// Test with different memory and iteration parameters
+		Argon2Function lowMemory = Argon2Function.getInstance(1024, 2, 1, 16, Argon2.ID);
+		Argon2Function highMemory = Argon2Function.getInstance(65536, 4, 2, 64, Argon2.ID);
+
+		Argon2Password4jPasswordEncoder lowEncoder = new Argon2Password4jPasswordEncoder(lowMemory);
+		Argon2Password4jPasswordEncoder highEncoder = new Argon2Password4jPasswordEncoder(highMemory);
+
+		String lowEncoded = lowEncoder.encode(PASSWORD);
+		String highEncoded = highEncoder.encode(PASSWORD);
+
+		assertThat(lowEncoder.matches(PASSWORD, lowEncoded)).isTrue();
+		assertThat(highEncoder.matches(PASSWORD, highEncoded)).isTrue();
+
+		// Each encoder should work with hashes generated by the same parameters
+		assertThat(lowEncoded).isNotEqualTo(highEncoded);
+	}
+
+}

+ 170 - 0
crypto/src/test/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoderTests.java

@@ -0,0 +1,170 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.crypto.password4j;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.BalloonHashingFunction;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link BalloonHashingPassword4jPasswordEncoder}.
+ *
+ * @author Mehrdad Bozorgmehr
+ */
+class BalloonHashingPassword4jPasswordEncoderTests {
+
+	private static final String PASSWORD = "password";
+
+	private static final String DIFFERENT_PASSWORD = "differentpassword";
+
+	@Test
+	void constructorWithNullFunctionShouldThrowException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> new BalloonHashingPassword4jPasswordEncoder(null))
+			.withMessage("balloonHashingFunction cannot be null");
+	}
+
+	@Test
+	void constructorWithInvalidSaltLengthShouldThrowException() {
+		BalloonHashingFunction function = AlgorithmFinder.getBalloonHashingInstance();
+		assertThatIllegalArgumentException().isThrownBy(() -> new BalloonHashingPassword4jPasswordEncoder(function, 0))
+			.withMessage("saltLength must be positive");
+		assertThatIllegalArgumentException().isThrownBy(() -> new BalloonHashingPassword4jPasswordEncoder(function, -1))
+			.withMessage("saltLength must be positive");
+	}
+
+	@Test
+	void defaultConstructorShouldWork() {
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD);
+		assertThat(encoded).contains(":");
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void customFunctionConstructorShouldWork() {
+		BalloonHashingFunction customFunction = BalloonHashingFunction.getInstance("SHA-256", 512, 2, 3);
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder(customFunction);
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD);
+		assertThat(encoded).contains(":");
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void customSaltLengthConstructorShouldWork() {
+		BalloonHashingFunction function = AlgorithmFinder.getBalloonHashingInstance();
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder(function, 16);
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD);
+		assertThat(encoded).contains(":");
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void encodeShouldIncludeSaltInOutput() {
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).contains(":");
+		String[] parts = encoded.split(":");
+		assertThat(parts).hasSize(2);
+		assertThat(parts[0]).isNotEmpty(); // salt part
+		assertThat(parts[1]).isNotEmpty(); // hash part
+	}
+
+	@Test
+	void matchesShouldReturnTrueForCorrectPassword() {
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+		boolean matches = encoder.matches(PASSWORD, encoded);
+
+		assertThat(matches).isTrue();
+	}
+
+	@Test
+	void matchesShouldReturnFalseForIncorrectPassword() {
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+		boolean matches = encoder.matches(DIFFERENT_PASSWORD, encoded);
+
+		assertThat(matches).isFalse();
+	}
+
+	@Test
+	void matchesShouldReturnFalseForMalformedEncodedPassword() {
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+		assertThat(encoder.matches(PASSWORD, "malformed")).isFalse();
+		assertThat(encoder.matches(PASSWORD, "no:delimiter:in:wrong:places")).isFalse();
+		assertThat(encoder.matches(PASSWORD, "invalid_base64!:hash")).isFalse();
+	}
+
+	@Test
+	void multipleEncodingsShouldProduceDifferentHashesButAllMatch() {
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+		String encoded1 = encoder.encode(PASSWORD);
+		String encoded2 = encoder.encode(PASSWORD);
+
+		assertThat(encoded1).isNotEqualTo(encoded2); // Different salts should produce
+														// different results
+		assertThat(encoder.matches(PASSWORD, encoded1)).isTrue();
+		assertThat(encoder.matches(PASSWORD, encoded2)).isTrue();
+	}
+
+	@Test
+	void upgradeEncodingShouldReturnFalse() {
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+		boolean shouldUpgrade = encoder.upgradeEncoding(encoded);
+
+		assertThat(shouldUpgrade).isFalse();
+	}
+
+	@Test
+	void encodeNullShouldReturnNull() {
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+		assertThat(encoder.encode(null)).isNull();
+	}
+
+	@Test
+	void matchesWithNullOrEmptyValuesShouldReturnFalse() {
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoder.matches(null, encoded)).isFalse();
+		assertThat(encoder.matches("", encoded)).isFalse();
+		assertThat(encoder.matches(PASSWORD, null)).isFalse();
+		assertThat(encoder.matches(PASSWORD, "")).isFalse();
+	}
+
+}

+ 217 - 0
crypto/src/test/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoderTests.java

@@ -0,0 +1,217 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.crypto.password4j;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.BcryptFunction;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link BcryptPassword4jPasswordEncoder}.
+ *
+ * @author Mehrdad Bozorgmehr
+ */
+class BcryptPassword4jPasswordEncoderTests {
+
+	private static final String PASSWORD = "password";
+
+	private static final String LONG_PASSWORD = "a".repeat(72); // BCrypt max length
+
+	private static final String VERY_LONG_PASSWORD = "a".repeat(100); // Beyond BCrypt max
+																		// length
+
+	private static final String SPECIAL_CHARS_PASSWORD = "p@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?";
+
+	private static final String UNICODE_PASSWORD = "пароль密码パスワード🔐";
+
+	@Test
+	void defaultConstructorShouldCreateWorkingEncoder() {
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull().matches("^\\$2[aby]?\\$.*");
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void constructorWithNullBcryptFunctionShouldThrowException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> new BcryptPassword4jPasswordEncoder(null))
+			.withMessage("hashingFunction cannot be null");
+	}
+
+	@Test
+	void constructorWithCustomBcryptFunctionShouldWork() {
+		BcryptFunction customFunction = BcryptFunction.getInstance(6);
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(customFunction);
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull().contains("$06$"); // 6 rounds
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@ParameterizedTest
+	@ValueSource(ints = { 4, 6, 8, 10, 12 })
+	void encodingShouldWorkWithDifferentRounds(int rounds) {
+		BcryptFunction function = BcryptFunction.getInstance(rounds);
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(function);
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull().contains(String.format("$%02d$", rounds));
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void encodingShouldGenerateDifferentHashesForSamePassword() {
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+
+		String hash1 = encoder.encode(PASSWORD);
+		String hash2 = encoder.encode(PASSWORD);
+
+		assertThat(hash1).isNotEqualTo(hash2);
+		assertThat(encoder.matches(PASSWORD, hash1)).isTrue();
+		assertThat(encoder.matches(PASSWORD, hash2)).isTrue();
+	}
+
+	@Test
+	void shouldHandleLongPasswords() {
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+
+		String encodedLong = encoder.encode(LONG_PASSWORD);
+		String encodedVeryLong = encoder.encode(VERY_LONG_PASSWORD);
+
+		assertThat(encoder.matches(LONG_PASSWORD, encodedLong)).isTrue();
+		assertThat(encoder.matches(VERY_LONG_PASSWORD, encodedVeryLong)).isTrue();
+	}
+
+	@Test
+	void shouldHandleSpecialCharacters() {
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+
+		String encoded = encoder.encode(SPECIAL_CHARS_PASSWORD);
+
+		assertThat(encoder.matches(SPECIAL_CHARS_PASSWORD, encoded)).isTrue();
+		assertThat(encoder.matches("wrong", encoded)).isFalse();
+	}
+
+	@Test
+	void shouldHandleUnicodeCharacters() {
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+
+		String encoded = encoder.encode(UNICODE_PASSWORD);
+
+		assertThat(encoder.matches(UNICODE_PASSWORD, encoded)).isTrue();
+		assertThat(encoder.matches("wrong", encoded)).isFalse();
+	}
+
+	@Test
+	void shouldRejectIncorrectPasswords() {
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoder.matches("wrongpassword", encoded)).isFalse();
+		assertThat(encoder.matches("PASSWORD", encoded)).isFalse(); // Case sensitive
+		assertThat(encoder.matches("password ", encoded)).isFalse(); // Trailing space
+		assertThat(encoder.matches(" password", encoded)).isFalse(); // Leading space
+	}
+
+	@Test
+	void matchesShouldReturnFalseForNullOrEmptyInputs() {
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoder.matches(null, encoded)).isFalse();
+		assertThat(encoder.matches("", encoded)).isFalse();
+		assertThat(encoder.matches(PASSWORD, null)).isFalse();
+		assertThat(encoder.matches(PASSWORD, "")).isFalse();
+		assertThat(encoder.matches(null, null)).isFalse();
+		assertThat(encoder.matches("", "")).isFalse();
+	}
+
+	@Test
+	void encodeNullShouldReturnNull() {
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+
+		assertThat(encoder.encode(null)).isNull();
+	}
+
+	@Test
+	void upgradeEncodingShouldReturnFalse() {
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoder.upgradeEncoding(encoded)).isFalse();
+	}
+
+	@Test
+	void shouldWorkWithAlgorithmFinderDefaults() {
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(
+				AlgorithmFinder.getBcryptInstance());
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull();
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void shouldRejectMalformedHashes() {
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+		assertMalformedRejected(encoder, PASSWORD, "invalid_hash");
+		assertMalformedRejected(encoder, PASSWORD, "$2a$10$invalid");
+		assertMalformedRejected(encoder, PASSWORD, "");
+	}
+
+	private void assertMalformedRejected(BcryptPassword4jPasswordEncoder encoder, String raw, String malformed) {
+		boolean rejected;
+		try {
+			rejected = !encoder.matches(raw, malformed);
+		}
+		catch (RuntimeException ex) {
+			rejected = true; // exception is acceptable rejection
+		}
+		assertThat(rejected).as("Malformed hash should not validate: " + malformed).isTrue();
+	}
+
+	@Test
+	void shouldHandleEmptyStringPassword() {
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+		String encoded = encoder.encode("");
+		assertThat(encoded).isNotNull();
+		boolean emptyMatches;
+		try {
+			emptyMatches = encoder.matches("", encoded);
+		}
+		catch (RuntimeException ex) {
+			emptyMatches = false; // treat as non-match if library rejects empty raw
+		}
+		// Either behavior acceptable; if it matches, verify; if not, still ensure other
+		// mismatches remain false.
+		if (emptyMatches) {
+			assertThat(encoder.matches("", encoded)).isTrue();
+		}
+		assertThat(encoder.matches("notEmpty", encoded)).isFalse();
+	}
+
+}

+ 105 - 0
crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java

@@ -0,0 +1,105 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.crypto.password4j;
+
+import com.password4j.BcryptFunction;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Base functionality tests for {@link Password4jPasswordEncoder} implementations. These
+ * tests verify the common behavior across all concrete password encoder subclasses.
+ *
+ * @author Mehrdad Bozorgmehr
+ */
+class Password4jPasswordEncoderTests {
+
+	private static final String PASSWORD = "password";
+
+	private static final String WRONG_PASSWORD = "wrongpassword";
+
+	// Test abstract class behavior through concrete implementation
+	@Test
+	void encodeShouldReturnNonNullHashedPassword() {
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
+
+		String result = encoder.encode(PASSWORD);
+
+		assertThat(result).isNotNull().isNotEqualTo(PASSWORD);
+	}
+
+	@Test
+	void matchesShouldReturnTrueForValidPassword() {
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
+
+		String encoded = encoder.encode(PASSWORD);
+		boolean result = encoder.matches(PASSWORD, encoded);
+
+		assertThat(result).isTrue();
+	}
+
+	@Test
+	void matchesShouldReturnFalseForInvalidPassword() {
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
+
+		String encoded = encoder.encode(PASSWORD);
+		boolean result = encoder.matches(WRONG_PASSWORD, encoded);
+
+		assertThat(result).isFalse();
+	}
+
+	@Test
+	void encodeNullPasswordShouldReturnNull() {
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
+
+		assertThat(encoder.encode(null)).isNull();
+	}
+
+	@Test
+	void multipleEncodesProduceDifferentHashesButAllMatch() {
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
+
+		String encoded1 = encoder.encode(PASSWORD);
+		String encoded2 = encoder.encode(PASSWORD);
+		// Bcrypt should produce different salted hashes for the same raw password
+		assertThat(encoded1).isNotEqualTo(encoded2);
+		assertThat(encoder.matches(PASSWORD, encoded1)).isTrue();
+		assertThat(encoder.matches(PASSWORD, encoded2)).isTrue();
+	}
+
+	@Test
+	void upgradeEncodingShouldReturnFalse() {
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
+
+		String encoded = encoder.encode(PASSWORD);
+		boolean result = encoder.upgradeEncoding(encoded);
+
+		assertThat(result).isFalse();
+	}
+
+	@Test
+	void matchesShouldReturnFalseWhenRawOrEncodedNullOrEmpty() {
+		BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
+		String encoded = encoder.encode(PASSWORD);
+		assertThat(encoder.matches(null, encoded)).isFalse();
+		assertThat(encoder.matches("", encoded)).isFalse();
+		assertThat(encoder.matches(PASSWORD, null)).isFalse();
+		assertThat(encoder.matches(PASSWORD, "")).isFalse();
+	}
+
+}

+ 153 - 0
crypto/src/test/java/org/springframework/security/crypto/password4j/PasswordCompatibilityTests.java

@@ -0,0 +1,153 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.crypto.password4j;
+
+import com.password4j.Argon2Function;
+import com.password4j.BcryptFunction;
+import com.password4j.ScryptFunction;
+import com.password4j.types.Argon2;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder;
+import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests compatibility between existing Spring Security password encoders and
+ * Password4j-based password encoders.
+ *
+ * @author Mehrdad Bozorgmehr
+ */
+class PasswordCompatibilityTests {
+
+	private static final String PASSWORD = "password";
+
+	// BCrypt Compatibility Tests
+	@Test
+	void bcryptEncodedWithSpringSecurityShouldMatchWithPassword4j() {
+		BCryptPasswordEncoder springEncoder = new BCryptPasswordEncoder(10);
+		BcryptPassword4jPasswordEncoder password4jEncoder = new BcryptPassword4jPasswordEncoder(
+				BcryptFunction.getInstance(10));
+
+		String encodedBySpring = springEncoder.encode(PASSWORD);
+		boolean matchedByPassword4j = password4jEncoder.matches(PASSWORD, encodedBySpring);
+
+		assertThat(matchedByPassword4j).isTrue();
+	}
+
+	@Test
+	void bcryptEncodedWithPassword4jShouldMatchWithSpringSecurity() {
+		BcryptPassword4jPasswordEncoder password4jEncoder = new BcryptPassword4jPasswordEncoder(
+				BcryptFunction.getInstance(10));
+		BCryptPasswordEncoder springEncoder = new BCryptPasswordEncoder(10);
+
+		String encodedByPassword4j = password4jEncoder.encode(PASSWORD);
+		boolean matchedBySpring = springEncoder.matches(PASSWORD, encodedByPassword4j);
+
+		assertThat(matchedBySpring).isTrue();
+	}
+
+	// Argon2 Compatibility Tests
+	@Test
+	void argon2EncodedWithSpringSecurityShouldMatchWithPassword4j() {
+		Argon2PasswordEncoder springEncoder = new Argon2PasswordEncoder(16, 32, 1, 4096, 3);
+		Argon2Password4jPasswordEncoder password4jEncoder = new Argon2Password4jPasswordEncoder(
+				Argon2Function.getInstance(4096, 3, 1, 32, Argon2.ID));
+
+		String encodedBySpring = springEncoder.encode(PASSWORD);
+		boolean matchedByPassword4j = password4jEncoder.matches(PASSWORD, encodedBySpring);
+
+		assertThat(matchedByPassword4j).isTrue();
+	}
+
+	@Test
+	void argon2EncodedWithPassword4jShouldMatchWithSpringSecurity() {
+		Argon2Password4jPasswordEncoder password4jEncoder = new Argon2Password4jPasswordEncoder(
+				Argon2Function.getInstance(4096, 3, 1, 32, Argon2.ID));
+		Argon2PasswordEncoder springEncoder = new Argon2PasswordEncoder(16, 32, 1, 4096, 3);
+
+		String encodedByPassword4j = password4jEncoder.encode(PASSWORD);
+		boolean matchedBySpring = springEncoder.matches(PASSWORD, encodedByPassword4j);
+
+		assertThat(matchedBySpring).isTrue();
+	}
+
+	// SCrypt Compatibility Tests
+	@Test
+	void scryptEncodedWithSpringSecurityShouldMatchWithPassword4j() {
+		SCryptPasswordEncoder springEncoder = new SCryptPasswordEncoder(16384, 8, 1, 32, 64);
+		ScryptPassword4jPasswordEncoder password4jEncoder = new ScryptPassword4jPasswordEncoder(
+				ScryptFunction.getInstance(16384, 8, 1, 32));
+
+		String encodedBySpring = springEncoder.encode(PASSWORD);
+		boolean matchedByPassword4j = password4jEncoder.matches(PASSWORD, encodedBySpring);
+
+		assertThat(matchedByPassword4j).isTrue();
+	}
+
+	@Test
+	void scryptEncodedWithPassword4jShouldMatchWithSpringSecurity() {
+		ScryptPassword4jPasswordEncoder password4jEncoder = new ScryptPassword4jPasswordEncoder(
+				ScryptFunction.getInstance(16384, 8, 1, 32));
+		SCryptPasswordEncoder springEncoder = new SCryptPasswordEncoder(16384, 8, 1, 32, 64);
+
+		String encodedByPassword4j = password4jEncoder.encode(PASSWORD);
+		boolean matchedBySpring = springEncoder.matches(PASSWORD, encodedByPassword4j);
+
+		assertThat(matchedBySpring).isTrue();
+	}
+
+	// PBKDF2 Compatibility Tests
+	@Test
+	void pbkdf2EncodedWithSpringSecurityCannotMatchWithPassword4j() {
+		// Note: Direct compatibility between Spring Security's Pbkdf2PasswordEncoder
+		// and Password4j's PBKDF2 implementation is not possible because they use
+		// different output formats. Spring Security uses hex encoding with a specific
+		// format,
+		// while our Password4jPasswordEncoder uses salt:hash format with Base64 encoding.
+		Pbkdf2PasswordEncoder springEncoder = Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8();
+		Pbkdf2Password4jPasswordEncoder password4jEncoder = new Pbkdf2Password4jPasswordEncoder();
+
+		String encodedBySpring = springEncoder.encode(PASSWORD);
+		String encodedByPassword4j = password4jEncoder.encode(PASSWORD);
+
+		// These should NOT match due to different formats
+		// Spring Security will throw an exception when trying to decode Password4j
+		// format,
+		// which should be treated as a non-match
+		boolean password4jCanMatchSpring = password4jEncoder.matches(PASSWORD, encodedBySpring);
+		boolean springCanMatchPassword4j;
+		try {
+			springCanMatchPassword4j = springEncoder.matches(PASSWORD, encodedByPassword4j);
+		}
+		catch (IllegalArgumentException ex) {
+			// Expected exception due to format incompatibility - treat as non-match
+			springCanMatchPassword4j = false;
+		}
+
+		assertThat(password4jCanMatchSpring).isFalse();
+		assertThat(springCanMatchPassword4j).isFalse();
+
+		// But each should match its own encoding
+		assertThat(springEncoder.matches(PASSWORD, encodedBySpring)).isTrue();
+		assertThat(password4jEncoder.matches(PASSWORD, encodedByPassword4j)).isTrue();
+	}
+
+}

+ 167 - 0
crypto/src/test/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoderTests.java

@@ -0,0 +1,167 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.crypto.password4j;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.PBKDF2Function;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link Pbkdf2Password4jPasswordEncoder}.
+ *
+ * @author Mehrdad Bozorgmehr
+ */
+class Pbkdf2Password4jPasswordEncoderTests {
+
+	private static final String PASSWORD = "password";
+
+	private static final String DIFFERENT_PASSWORD = "differentpassword";
+
+	@Test
+	void constructorWithNullFunctionShouldThrowException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> new Pbkdf2Password4jPasswordEncoder(null))
+			.withMessage("pbkdf2Function cannot be null");
+	}
+
+	@Test
+	void constructorWithInvalidSaltLengthShouldThrowException() {
+		PBKDF2Function function = AlgorithmFinder.getPBKDF2Instance();
+		assertThatIllegalArgumentException().isThrownBy(() -> new Pbkdf2Password4jPasswordEncoder(function, 0))
+			.withMessage("saltLength must be positive");
+		assertThatIllegalArgumentException().isThrownBy(() -> new Pbkdf2Password4jPasswordEncoder(function, -1))
+			.withMessage("saltLength must be positive");
+	}
+
+	@Test
+	void defaultConstructorShouldWork() {
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD).contains(":");
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void customFunctionConstructorShouldWork() {
+		PBKDF2Function customFunction = AlgorithmFinder.getPBKDF2Instance();
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder(customFunction);
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD).contains(":");
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void customSaltLengthConstructorShouldWork() {
+		PBKDF2Function function = AlgorithmFinder.getPBKDF2Instance();
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder(function, 16);
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD).contains(":");
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void encodeShouldIncludeSaltInOutput() {
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).contains(":");
+		String[] parts = encoded.split(":");
+		assertThat(parts).hasSize(2);
+		assertThat(parts[0]).isNotEmpty(); // salt part
+		assertThat(parts[1]).isNotEmpty(); // hash part
+	}
+
+	@Test
+	void matchesShouldReturnTrueForCorrectPassword() {
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+		boolean matches = encoder.matches(PASSWORD, encoded);
+
+		assertThat(matches).isTrue();
+	}
+
+	@Test
+	void matchesShouldReturnFalseForIncorrectPassword() {
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+		boolean matches = encoder.matches(DIFFERENT_PASSWORD, encoded);
+
+		assertThat(matches).isFalse();
+	}
+
+	@Test
+	void matchesShouldReturnFalseForMalformedEncodedPassword() {
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+		assertThat(encoder.matches(PASSWORD, "malformed")).isFalse();
+		assertThat(encoder.matches(PASSWORD, "no:delimiter:in:wrong:places")).isFalse();
+		assertThat(encoder.matches(PASSWORD, "invalid_base64!:hash")).isFalse();
+	}
+
+	@Test
+	void multipleEncodingsShouldProduceDifferentHashesButAllMatch() {
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+		String encoded1 = encoder.encode(PASSWORD);
+		String encoded2 = encoder.encode(PASSWORD);
+
+		assertThat(encoded1).isNotEqualTo(encoded2); // Different salts should produce
+														// different results
+		assertThat(encoder.matches(PASSWORD, encoded1)).isTrue();
+		assertThat(encoder.matches(PASSWORD, encoded2)).isTrue();
+	}
+
+	@Test
+	void upgradeEncodingShouldReturnFalse() {
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+		boolean shouldUpgrade = encoder.upgradeEncoding(encoded);
+
+		assertThat(shouldUpgrade).isFalse();
+	}
+
+	@Test
+	void encodeNullShouldReturnNull() {
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+		assertThat(encoder.encode(null)).isNull();
+	}
+
+	@Test
+	void matchesWithNullOrEmptyValuesShouldReturnFalse() {
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoder.matches(null, encoded)).isFalse();
+		assertThat(encoder.matches("", encoded)).isFalse();
+		assertThat(encoder.matches(PASSWORD, null)).isFalse();
+		assertThat(encoder.matches(PASSWORD, "")).isFalse();
+	}
+
+}

+ 248 - 0
crypto/src/test/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoderTests.java

@@ -0,0 +1,248 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.crypto.password4j;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.ScryptFunction;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link ScryptPassword4jPasswordEncoder}.
+ *
+ * @author Mehrdad Bozorgmehr
+ */
+class ScryptPassword4jPasswordEncoderTests {
+
+	private static final String PASSWORD = "password";
+
+	private static final String LONG_PASSWORD = "a".repeat(1000);
+
+	private static final String SPECIAL_CHARS_PASSWORD = "p@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?";
+
+	private static final String UNICODE_PASSWORD = "пароль密码パスワード🔐";
+
+	@Test
+	void defaultConstructorShouldCreateWorkingEncoder() {
+		ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull();
+		// Password4j scrypt format differs from classic $s0$; accept generic multi-part
+		// format
+		assertThat(encoded.split("\\$").length).isGreaterThanOrEqualTo(3);
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void constructorWithNullScryptFunctionShouldThrowException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> new ScryptPassword4jPasswordEncoder(null))
+			.withMessage("hashingFunction cannot be null");
+	}
+
+	@Test
+	void constructorWithCustomScryptFunctionShouldWork() {
+		ScryptFunction customFunction = ScryptFunction.getInstance(16384, 8, 1, 32);
+		ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(customFunction);
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull();
+		assertThat(encoded.split("\\$").length).isGreaterThanOrEqualTo(3);
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@ParameterizedTest
+	@CsvSource({ "1024, 8, 1, 16", "4096, 8, 1, 32", "16384, 8, 1, 32", "32768, 8, 1, 64" })
+	void encodingShouldWorkWithDifferentParameters(int N, int r, int p, int dkLen) {
+		ScryptFunction function = ScryptFunction.getInstance(N, r, p, dkLen);
+		ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(function);
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull();
+		assertThat(encoded.split("\\$").length).isGreaterThanOrEqualTo(3);
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void encodingShouldGenerateDifferentHashesForSamePassword() {
+		ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+
+		String hash1 = encoder.encode(PASSWORD);
+		String hash2 = encoder.encode(PASSWORD);
+
+		assertThat(hash1).isNotEqualTo(hash2);
+		assertThat(encoder.matches(PASSWORD, hash1)).isTrue();
+		assertThat(encoder.matches(PASSWORD, hash2)).isTrue();
+	}
+
+	@Test
+	void shouldHandleLongPasswords() {
+		ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+
+		String encoded = encoder.encode(LONG_PASSWORD);
+
+		assertThat(encoder.matches(LONG_PASSWORD, encoded)).isTrue();
+		assertThat(encoder.matches("wrong", encoded)).isFalse();
+	}
+
+	@Test
+	void shouldHandleSpecialCharacters() {
+		ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+
+		String encoded = encoder.encode(SPECIAL_CHARS_PASSWORD);
+
+		assertThat(encoder.matches(SPECIAL_CHARS_PASSWORD, encoded)).isTrue();
+		assertThat(encoder.matches("wrong", encoded)).isFalse();
+	}
+
+	@Test
+	void shouldHandleUnicodeCharacters() {
+		ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+
+		String encoded = encoder.encode(UNICODE_PASSWORD);
+
+		assertThat(encoder.matches(UNICODE_PASSWORD, encoded)).isTrue();
+		assertThat(encoder.matches("wrong", encoded)).isFalse();
+	}
+
+	@Test
+	void shouldRejectIncorrectPasswords() {
+		ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoder.matches("wrongpassword", encoded)).isFalse();
+		assertThat(encoder.matches("PASSWORD", encoded)).isFalse(); // Case sensitive
+		assertThat(encoder.matches("password ", encoded)).isFalse(); // Trailing space
+		assertThat(encoder.matches(" password", encoded)).isFalse(); // Leading space
+	}
+
+	@Test
+	void matchesShouldReturnFalseForNullOrEmptyInputs() {
+		ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoder.matches(null, encoded)).isFalse();
+		assertThat(encoder.matches("", encoded)).isFalse();
+		assertThat(encoder.matches(PASSWORD, null)).isFalse();
+		assertThat(encoder.matches(PASSWORD, "")).isFalse();
+		assertThat(encoder.matches(null, null)).isFalse();
+		assertThat(encoder.matches("", "")).isFalse();
+	}
+
+	@Test
+	void encodeNullShouldReturnNull() {
+		ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+
+		assertThat(encoder.encode(null)).isNull();
+	}
+
+	@Test
+	void upgradeEncodingShouldReturnFalse() {
+		ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoder.upgradeEncoding(encoded)).isFalse();
+	}
+
+	@Test
+	void shouldWorkWithAlgorithmFinderDefaults() {
+		ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(
+				AlgorithmFinder.getScryptInstance());
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull();
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void shouldRejectMalformedHashes() {
+		ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+		assertMalformedRejected(encoder, PASSWORD, "invalid_hash");
+		assertMalformedRejected(encoder, PASSWORD, "$s0$invalid");
+		assertMalformedRejected(encoder, PASSWORD, "");
+	}
+
+	private void assertMalformedRejected(ScryptPassword4jPasswordEncoder encoder, String raw, String malformed) {
+		boolean rejected;
+		try {
+			rejected = !encoder.matches(raw, malformed);
+		}
+		catch (RuntimeException ex) {
+			rejected = true; // exception path acceptable
+		}
+		assertThat(rejected).as("Malformed hash should not validate: " + malformed).isTrue();
+	}
+
+	@Test
+	void shouldHandleEmptyStringPassword() {
+		ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+		String encoded = encoder.encode("");
+		assertThat(encoded).isNotNull();
+		boolean emptyMatches;
+		try {
+			emptyMatches = encoder.matches("", encoded);
+		}
+		catch (RuntimeException ex) {
+			emptyMatches = false;
+		}
+		if (emptyMatches) {
+			assertThat(encoder.matches("", encoded)).isTrue();
+		}
+		assertThat(encoder.matches("notEmpty", encoded)).isFalse();
+	}
+
+	@Test
+	void shouldHandleCustomCostParameters() {
+		// Test with low cost parameters for speed
+		ScryptFunction lowCost = ScryptFunction.getInstance(1024, 1, 1, 16);
+		// Test with higher cost parameters
+		ScryptFunction highCost = ScryptFunction.getInstance(32768, 8, 2, 64);
+
+		ScryptPassword4jPasswordEncoder lowEncoder = new ScryptPassword4jPasswordEncoder(lowCost);
+		ScryptPassword4jPasswordEncoder highEncoder = new ScryptPassword4jPasswordEncoder(highCost);
+
+		String lowEncoded = lowEncoder.encode(PASSWORD);
+		String highEncoded = highEncoder.encode(PASSWORD);
+
+		assertThat(lowEncoder.matches(PASSWORD, lowEncoded)).isTrue();
+		assertThat(highEncoder.matches(PASSWORD, highEncoded)).isTrue();
+
+		// Each encoder should work with hashes generated by the same parameters
+		assertThat(lowEncoded).isNotEqualTo(highEncoded);
+	}
+
+	@Test
+	void shouldHandleEdgeCaseParameters() {
+		// Test with minimum practical parameters
+		ScryptFunction minParams = ScryptFunction.getInstance(2, 1, 1, 1);
+		ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(minParams);
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull();
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+}

+ 1 - 1
dependencies/spring-security-dependencies.gradle

@@ -78,6 +78,6 @@ dependencies {
 		api libs.org.apache.maven.resolver.maven.resolver.transport.http
 		api libs.org.apache.maven.maven.resolver.provider
 		api libs.org.instancio.instancio.junit
+		api libs.com.password4j.password4j
 	}
 }
-

+ 109 - 0
docs/modules/ROOT/pages/features/authentication/password-storage.adoc

@@ -463,6 +463,115 @@ There are a significant number of other `PasswordEncoder` implementations that e
 They are all deprecated to indicate that they are no longer considered secure.
 However, there are no plans to remove them, since it is difficult to migrate existing legacy systems.
 
+[[password4j]]
+== Password4j-based Password Encoders
+
+Spring Security 7.0 introduces alternative password encoder implementations based on the https://github.com/Password4j/password4j[Password4j] library.
+These encoders provide additional options for popular hashing algorithms and can be used as alternatives to the existing Spring Security implementations.
+
+The Password4j library is a Java cryptographic library that focuses on password hashing with support for multiple algorithms.
+These encoders are particularly useful when you need specific algorithm configurations or want to leverage Password4j's optimizations.
+
+All Password4j-based encoders are thread-safe and can be shared across multiple threads.
+
+[[password4j-argon2]]
+=== Argon2Password4jPasswordEncoder
+
+The `Argon2Password4jPasswordEncoder` implementation uses the https://en.wikipedia.org/wiki/Argon2[Argon2] algorithm via the Password4j library to hash passwords.
+This provides an alternative to Spring Security's built-in `Argon2PasswordEncoder` with different configuration options and potential performance characteristics.
+
+Argon2 is the winner of the https://en.wikipedia.org/wiki/Password_Hashing_Competition[Password Hashing Competition] and is recommended for new applications.
+This implementation leverages Password4j's Argon2 support which properly includes the salt in the output hash.
+
+Create an encoder with default settings:
+
+.Argon2Password4jPasswordEncoder
+include-code::./Argon2UsageTests[tag=default-params,indent=0]
+
+Create an encoder with custom Argon2 parameters:
+
+.Argon2Password4jPasswordEncoder Custom
+include-code::./Argon2UsageTests[tag=custom-params,indent=0]
+
+[[password4j-bcrypt]]
+=== BcryptPassword4jPasswordEncoder
+
+The `BcryptPassword4jPasswordEncoder` implementation uses the https://en.wikipedia.org/wiki/Bcrypt[BCrypt] algorithm via the Password4j library to hash passwords.
+This provides an alternative to Spring Security's built-in `BCryptPasswordEncoder` with Password4j's implementation characteristics.
+
+BCrypt is a well-established password hashing algorithm that includes built-in salt generation and is resistant to rainbow table attacks.
+This implementation leverages Password4j's BCrypt support which properly includes the salt in the output hash.
+
+Create an encoder with default settings:
+
+.BcryptPassword4jPasswordEncoder
+include-code::./BcryptUsageTests[tag=default-params,indent=0]
+
+Create an encoder with custom bcrypt parameters:
+
+.BcryptPassword4jPasswordEncoder Custom
+include-code::./BcryptUsageTests[tag=custom-params,indent=0]
+
+[[password4j-scrypt]]
+=== ScryptPassword4jPasswordEncoder
+
+The `ScryptPassword4jPasswordEncoder` implementation uses the https://en.wikipedia.org/wiki/Scrypt[SCrypt] algorithm via the Password4j library to hash passwords.
+This provides an alternative to Spring Security's built-in `SCryptPasswordEncoder` with Password4j's implementation characteristics.
+
+SCrypt is a memory-hard password hashing algorithm designed to be resistant to hardware brute-force attacks.
+This implementation leverages Password4j's SCrypt support which properly includes the salt in the output hash.
+
+
+Create an encoder with default settings:
+
+.ScryptPassword4jPasswordEncoder
+include-code::./ScryptUsageTests[tag=default-params,indent=0]
+
+Create an encoder with custom scrypt parameters:
+
+.ScryptPassword4jPasswordEncoder Custom
+include-code::./ScryptUsageTests[tag=custom-params,indent=0]
+
+[[password4j-pbkdf2]]
+=== Pbkdf2Password4jPasswordEncoder
+
+The `Pbkdf2Password4jPasswordEncoder` implementation uses the https://en.wikipedia.org/wiki/PBKDF2[PBKDF2] algorithm via the Password4j library to hash passwords.
+This provides an alternative to Spring Security's built-in `Pbkdf2PasswordEncoder` with explicit salt management.
+
+PBKDF2 is a key derivation function designed to be computationally expensive to thwart dictionary and brute force attacks.
+This implementation handles salt management explicitly since Password4j's PBKDF2 implementation does not include the salt in the output hash.
+The encoded password format is: `+{salt}:{hash}+` where both salt and hash are Base64 encoded.
+
+Create an encoder with default settings:
+
+.Pbkdf2Password4jPasswordEncoder
+include-code::./Pbkdf2UsageTests[tag=default-params,indent=0]
+
+Create an encoder with custom PBKDF2 parameters:
+
+.Pbkdf2Password4jPasswordEncoder Custom
+include-code::./Pbkdf2UsageTests[tag=custom-params,indent=0]
+
+[[password4j-ballooning]]
+=== BalloonHashingPassword4jPasswordEncoder
+
+The `BalloonHashingPassword4jPasswordEncoder` implementation uses the Balloon hashing algorithm via the Password4j library to hash passwords.
+Balloon hashing is a memory-hard password hashing algorithm designed to be resistant to both time-memory trade-off attacks and side-channel attacks.
+
+This implementation handles salt management explicitly since Password4j's Balloon hashing implementation does not include the salt in the output hash.
+The encoded password format is: `+{salt}:{hash}+` where both salt and hash are Base64 encoded.
+
+
+Create an encoder with default settings:
+
+.BalloonHashingPassword4jPasswordEncoder
+include-code::./BallooningHashingUsageTests[tag=default-params,indent=0]
+
+Create an encoder with custom parameters:
+
+.BalloonHashingPassword4jPasswordEncoder Custom
+include-code::./BallooningHashingUsageTests[tag=custom-params,indent=0]
+
 [[authentication-password-storage-configuration]]
 == Password Storage Configuration
 

+ 9 - 0
docs/modules/ROOT/pages/whats-new.adoc

@@ -35,6 +35,15 @@ Java::
 http.csrf((csrf) -> csrf.spa());
 ----
 
+== Crypto
+
+* Added Password4j-based password encoders providing alternative implementations for popular hashing algorithms:
+** `Argon2Password4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#password4j-argon2[Argon2]
+** `BcryptPassword4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#password4j-bcrypt[BCrypt]
+** `ScryptPassword4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#password4j-scrypt[SCrypt]
+** `Pbkdf2Password4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#password4j-pbkdf2[PBKDF2]
+** `BalloonHashingPassword4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#password4j-balloon[Balloon Hashing]
+
 == Data
 
 * Added support to Authorized objects for Spring Data types

+ 1 - 0
docs/spring-security-docs.gradle

@@ -39,6 +39,7 @@ dependencies {
 	testImplementation project(':spring-security-test')
 	testImplementation project(':spring-security-oauth2-client')
 	testImplementation 'com.squareup.okhttp3:mockwebserver'
+	testImplementation libs.com.password4j.password4j
 	testImplementation 'com.unboundid:unboundid-ldapsdk'
 	testImplementation libs.webauthn4j.core
 	testImplementation 'org.jetbrains.kotlin:kotlin-reflect'

+ 53 - 0
docs/src/test/java/org/springframework/security/docs/features/authentication/password4jargon2/Argon2UsageTests.java

@@ -0,0 +1,53 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.docs.features.authentication.password4jargon2;
+
+import com.password4j.Argon2Function;
+import com.password4j.types.Argon2;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.crypto.password4j.Argon2Password4jPasswordEncoder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Rob Winch
+ */
+public class Argon2UsageTests {
+
+	@Test
+	void defaultParams() {
+		// tag::default-params[]
+		PasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+		String result = encoder.encode("myPassword");
+		assertThat(encoder.matches("myPassword", result)).isTrue();
+		// end::default-params[]
+	}
+
+	@Test
+	void customParameters() {
+		// tag::custom-params[]
+		Argon2Function argon2Fn = Argon2Function.getInstance(65536, 3, 4, 32,
+				Argon2.ID);
+		PasswordEncoder encoder = new Argon2Password4jPasswordEncoder(argon2Fn);
+		String result = encoder.encode("myPassword");
+		assertThat(encoder.matches("myPassword", result)).isTrue();
+		// end::custom-params[]
+	}
+
+}

+ 52 - 0
docs/src/test/java/org/springframework/security/docs/features/authentication/password4jballooning/BallooningHashingUsageTests.java

@@ -0,0 +1,52 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.docs.features.authentication.password4jballooning;
+
+import com.password4j.BalloonHashingFunction;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.crypto.password4j.BalloonHashingPassword4jPasswordEncoder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Rob Winch
+ */
+public class BallooningHashingUsageTests {
+
+	@Test
+	void defaultParams() {
+		// tag::default-params[]
+		PasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+		String result = encoder.encode("myPassword");
+		assertThat(encoder.matches("myPassword", result)).isTrue();
+		// end::default-params[]
+	}
+
+	@Test
+	void customParameters() {
+		// tag::custom-params[]
+		BalloonHashingFunction ballooningHashingFn =
+			BalloonHashingFunction.getInstance("SHA-256", 1024, 3, 4, 3);
+		PasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder(ballooningHashingFn);
+		String result = encoder.encode("myPassword");
+		assertThat(encoder.matches("myPassword", result)).isTrue();
+		// end::custom-params[]
+	}
+
+}

+ 52 - 0
docs/src/test/java/org/springframework/security/docs/features/authentication/password4jbcrypt/BcryptUsageTests.java

@@ -0,0 +1,52 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.docs.features.authentication.password4jbcrypt;
+
+import com.password4j.BcryptFunction;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.crypto.password4j.BcryptPassword4jPasswordEncoder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Rob Winch
+ */
+public class BcryptUsageTests {
+
+	@Test
+	void defaultParams() {
+		// tag::default-params[]
+		PasswordEncoder encoder = new BCryptPasswordEncoder();
+		String result = encoder.encode("myPassword");
+		assertThat(encoder.matches("myPassword", result)).isTrue();
+		// end::default-params[]
+	}
+
+	@Test
+	void customParameters() {
+		// tag::custom-params[]
+		BcryptFunction bcryptFn = BcryptFunction.getInstance(12);
+		PasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(bcryptFn);
+		String result = encoder.encode("myPassword");
+		assertThat(encoder.matches("myPassword", result)).isTrue();
+		// end::custom-params[]
+	}
+
+}

+ 52 - 0
docs/src/test/java/org/springframework/security/docs/features/authentication/password4jpbkdf2/Pbkdf2UsageTests.java

@@ -0,0 +1,52 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.docs.features.authentication.password4jpbkdf2;
+
+import com.password4j.PBKDF2Function;
+import com.password4j.types.Hmac;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.crypto.password4j.Pbkdf2Password4jPasswordEncoder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Rob Winch
+ */
+public class Pbkdf2UsageTests {
+
+	@Test
+	void defaultParams() {
+		// tag::default-params[]
+		PasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+		String result = encoder.encode("myPassword");
+		assertThat(encoder.matches("myPassword", result)).isTrue();
+		// end::default-params[]
+	}
+
+	@Test
+	void customParameters() {
+		// tag::custom-params[]
+		PBKDF2Function pbkdf2Fn = PBKDF2Function.getInstance(Hmac.SHA256, 100000, 256);
+		PasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder(pbkdf2Fn);
+		String result = encoder.encode("myPassword");
+		assertThat(encoder.matches("myPassword", result)).isTrue();
+		// end::custom-params[]
+	}
+
+}

+ 51 - 0
docs/src/test/java/org/springframework/security/docs/features/authentication/password4jscrypt/ScryptUsageTests.java

@@ -0,0 +1,51 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.docs.features.authentication.password4jscrypt;
+
+import com.password4j.ScryptFunction;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.crypto.password4j.ScryptPassword4jPasswordEncoder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Rob Winch
+ */
+public class ScryptUsageTests {
+
+	@Test
+	void defaultParams() {
+		// tag::default-params[]
+		PasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+		String result = encoder.encode("myPassword");
+		assertThat(encoder.matches("myPassword", result)).isTrue();
+		// end::default-params[]
+	}
+
+	@Test
+	void customParameters() {
+		// tag::custom-params[]
+		ScryptFunction scryptFn = ScryptFunction.getInstance(32768, 8, 1, 32);
+		PasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(scryptFn);
+		String result = encoder.encode("myPassword");
+		assertThat(encoder.matches("myPassword", result)).isTrue();
+		// end::custom-params[]
+	}
+
+}

+ 51 - 0
docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jargon2/Argon2UsageTests.kt

@@ -0,0 +1,51 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.kt.docs.features.authentication.password4jargon2
+
+import com.password4j.Argon2Function
+import com.password4j.types.Argon2
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Test
+import org.springframework.security.crypto.password.PasswordEncoder
+import org.springframework.security.crypto.password4j.Argon2Password4jPasswordEncoder
+
+/**
+ * @author Rob Winch
+ */
+class Argon2UsageTests {
+
+    @Test
+    fun defaultParams() {
+        // tag::default-params[]
+        val encoder: PasswordEncoder = Argon2Password4jPasswordEncoder()
+        val result = encoder.encode("myPassword")
+        assertThat(encoder.matches("myPassword", result)).isTrue()
+        // end::default-params[]
+    }
+
+    @Test
+    fun customParameters() {
+        // tag::custom-params[]
+        val argon2Fn = Argon2Function.getInstance(
+            65536, 3, 4, 32,
+            Argon2.ID
+        )
+        val encoder: PasswordEncoder = Argon2Password4jPasswordEncoder(argon2Fn)
+        val result = encoder.encode("myPassword")
+        assertThat(encoder.matches("myPassword", result)).isTrue()
+        // end::custom-params[]
+    }
+}

+ 47 - 0
docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jballooning/BallooningHashingUsageTests.kt

@@ -0,0 +1,47 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.kt.docs.features.authentication.password4jballooning
+
+import com.password4j.BalloonHashingFunction
+import org.assertj.core.api.Assertions
+import org.junit.jupiter.api.Test
+import org.springframework.security.crypto.password.PasswordEncoder
+import org.springframework.security.crypto.password4j.BalloonHashingPassword4jPasswordEncoder
+
+/**
+ * @author Rob Winch
+ */
+class BallooningHashingUsageTests {
+    @Test
+    fun defaultParams() {
+        // tag::default-params[]
+        val encoder: PasswordEncoder = BalloonHashingPassword4jPasswordEncoder()
+        val result = encoder.encode("myPassword")
+        Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
+        // end::default-params[]
+    }
+
+    @Test
+    fun customParameters() {
+        // tag::custom-params[]
+        val ballooningHashingFn =
+            BalloonHashingFunction.getInstance("SHA-256", 1024, 3, 4, 3)
+        val encoder: PasswordEncoder = BalloonHashingPassword4jPasswordEncoder(ballooningHashingFn)
+        val result = encoder.encode("myPassword")
+        Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
+        // end::custom-params[]
+    }
+}

+ 32 - 0
docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jbcrypt/BcryptUsageTests.kt

@@ -0,0 +1,32 @@
+package org.springframework.security.kt.docs.features.authentication.password4jbcrypt
+
+import com.password4j.BcryptFunction
+import org.assertj.core.api.Assertions
+import org.junit.jupiter.api.Test
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
+import org.springframework.security.crypto.password.PasswordEncoder
+import org.springframework.security.crypto.password4j.BcryptPassword4jPasswordEncoder
+
+/**
+ * @author Rob Winch
+ */
+class BcryptUsageTests {
+    @Test
+    fun defaultParams() {
+        // tag::default-params[]
+        val encoder: PasswordEncoder = BCryptPasswordEncoder()
+        val result = encoder.encode("myPassword")
+        Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
+        // end::default-params[]
+    }
+
+    @Test
+    fun customParameters() {
+        // tag::custom-params[]
+        val bcryptFunction = BcryptFunction.getInstance(12)
+        val encoder: PasswordEncoder = BcryptPassword4jPasswordEncoder(bcryptFunction)
+        val result = encoder.encode("myPassword")
+        Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
+        // end::custom-params[]
+    }
+}

+ 32 - 0
docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jpbkdf2/Pbkdf2UsageTests.kt

@@ -0,0 +1,32 @@
+package org.springframework.security.kt.docs.features.authentication.password4jpbkdf2
+
+import com.password4j.PBKDF2Function
+import com.password4j.types.Hmac
+import org.assertj.core.api.Assertions
+import org.junit.jupiter.api.Test
+import org.springframework.security.crypto.password.PasswordEncoder
+import org.springframework.security.crypto.password4j.Pbkdf2Password4jPasswordEncoder
+
+/**
+ * @author Rob Winch
+ */
+class Pbkdf2UsageTests {
+    @Test
+    fun defaultParams() {
+        // tag::default-params[]
+        val encoder: PasswordEncoder = Pbkdf2Password4jPasswordEncoder()
+        val result = encoder.encode("myPassword")
+        Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
+        // end::default-params[]
+    }
+
+    @Test
+    fun customParameters() {
+        // tag::custom-params[]
+        val pbkdf2Fn = PBKDF2Function.getInstance(Hmac.SHA256, 100000, 256)
+        val encoder: PasswordEncoder = Pbkdf2Password4jPasswordEncoder(pbkdf2Fn)
+        val result = encoder.encode("myPassword")
+        Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
+        // end::custom-params[]
+    }
+}

+ 31 - 0
docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jscrypt/ScryptUsageTests.kt

@@ -0,0 +1,31 @@
+package org.springframework.security.kt.docs.features.authentication.password4jscrypt
+
+import com.password4j.ScryptFunction
+import org.assertj.core.api.Assertions
+import org.junit.jupiter.api.Test
+import org.springframework.security.crypto.password.PasswordEncoder
+import org.springframework.security.crypto.password4j.ScryptPassword4jPasswordEncoder
+
+/**
+ * @author Rob Winch
+ */
+class ScryptUsageTests {
+    @Test
+    fun defaultParams() {
+        // tag::default-params[]
+        val encoder: PasswordEncoder = ScryptPassword4jPasswordEncoder()
+        val result = encoder.encode("myPassword")
+        Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
+        // end::default-params[]
+    }
+
+    @Test
+    fun customParameters() {
+        // tag::custom-params[]
+        val scryptFn = ScryptFunction.getInstance(32768, 8, 1, 32)
+        val encoder: PasswordEncoder = ScryptPassword4jPasswordEncoder(scryptFn)
+        val result = encoder.encode("myPassword")
+        Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
+        // end::custom-params[]
+    }
+}

+ 2 - 0
gradle/libs.versions.toml

@@ -13,6 +13,7 @@ org-jetbrains-kotlinx = "1.10.2"
 org-mockito = "5.17.0"
 org-opensaml5 = "5.1.6"
 org-springframework = "7.0.0-M9"
+com-password4j = "1.8.2"
 
 [libraries]
 ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.5.18"
@@ -101,6 +102,7 @@ org-instancio-instancio-junit = "org.instancio:instancio-junit:3.7.1"
 
 spring-nullability = 'io.spring.nullability:io.spring.nullability.gradle.plugin:0.0.4'
 webauthn4j-core = 'com.webauthn4j:webauthn4j-core:0.29.6.RELEASE'
+com-password4j-password4j = { module = "com.password4j:password4j", version.ref = "com-password4j" }
 
 [plugins]