Guardar contraseñas correctamente: bcrypt y argon2
1. Descripción general
Guardar contraseñas de forma incorrecta es una de las vulnerabilidades más comunes que hace que los datos de los usuarios se expongan cuando la base de datos es atacada. Este artículo te ayudará a entender por qué no deberías usar MD5/SHA256 puro para hashear contraseñas, y cómo usar BCryptPasswordEncoder y Argon2PasswordEncoder en Spring Security. Después de leerlo, podrás configurar un PasswordEncoder seguro, escribir tests que verifiquen el comportamiento, y saber qué algoritmo elegir para tu proyecto.
2. Por qué no deberías hashear contraseñas con SHA-256 puro
Las funciones hash criptográficas como SHA-256 y SHA-512 están diseñadas para ejecutarse lo más rápido posible. Eso es una ventaja al verificar la integridad de archivos, pero es un desastre cuando se usa para contraseñas.
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
public class NaiveHasher {
public static String sha256(String password) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(password.getBytes(StandardCharsets.UTF_8));
return java.util.HexFormat.of().formatHex(hash);
}
}
La función anterior puede hashear miles de millones de contraseñas por segundo en una GPU promedio. Un atacante solo necesita una tabla rainbow o fuerza bruta para descubrir la contraseña original, especialmente con contraseñas comunes como 123456 o password1.
Dos problemas fundamentales:
- Sin salt: dos usuarios con la misma contraseña producirán el mismo hash, lo que es vulnerable a ataques con tabla rainbow.
- Demasiado rápido: no hay forma de “ralentizar” a los atacantes.
Nota: No agregues salt manualmente y lo concatenes a SHA-256. Sigue siendo una solución débil porque carece de “work factor” — la dificultad computacional no aumenta conforme el hardware se vuelve más potente.
3. ¿Qué es una función de hash de contraseña?
Algoritmos como bcrypt, scrypt y argon2 están diseñados específicamente para hashear contraseñas, con tres características clave:
- Salt automático: cada vez que se hashea, se genera un salt aleatorio que se incrusta en la salida del hash.
- Lentitud intencional (work factor): un parámetro que aumenta la dificultad, resistiendo ataques de fuerza bruta conforme el hardware mejora.
- Memory-hard (con argon2/scrypt): consume mucha RAM, haciendo que la ejecución paralela en GPU/ASIC sea menos eficiente.
El resultado de un hash bcrypt se ve así:
$2a$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW
Estructura: $<version>$<cost>$<salt+hash base64>. Aquí 10 es el cost factor (2^10 iteraciones).
4. Usar bcrypt con Spring Security
4.1. Dependencia
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
BCryptPasswordEncoder ya viene incluido en spring-security-crypto, no necesitas agregar nada más.
4.2. Declarar el bean
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // cost factor = 12
}
}
El parámetro 12 determina que el número de iteraciones es 2^12. Un cost factor más alto → más lento → más seguro, pero también consume más CPU durante el login. Valores de 10–12 son razonables para la mayoría de aplicaciones en 2026.
4.3. Usar durante el registro y login
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class UserRegistrationService {
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
public UserRegistrationService(PasswordEncoder passwordEncoder, UserRepository userRepository) {
this.passwordEncoder = passwordEncoder;
this.userRepository = userRepository;
}
public void register(String email, String rawPassword) {
String hashed = passwordEncoder.encode(rawPassword);
userRepository.save(new Customer(email, hashed));
}
public boolean authenticate(String email, String rawPassword) {
Customer customer = userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("El email no existe"));
return passwordEncoder.matches(rawPassword, customer.getPasswordHash());
}
}
encode()genera un salt aleatorio cada vez que se llama, así que la mismarawPasswordproducirá un hash diferente cada vez — este es el comportamiento correcto, no un bug.matches()extrae automáticamente el salt del hash almacenado en la BD, hashea de nuevo la contraseña ingresada y luego compara, sin que necesites gestionar el salt manualmente.
4.4. Test del comportamiento
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat;
class BcryptPasswordEncoderTest {
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);
@Test
void shouldGenerateDifferentHashesForSamePassword() {
String hash1 = encoder.encode("S3cretPass!");
String hash2 = encoder.encode("S3cretPass!");
assertThat(hash1).isNotEqualTo(hash2);
assertThat(encoder.matches("S3cretPass!", hash1)).isTrue();
assertThat(encoder.matches("S3cretPass!", hash2)).isTrue();
}
@Test
void shouldRejectWrongPassword() {
String hash = encoder.encode("S3cretPass!");
assertThat(encoder.matches("WrongPass!", hash)).isFalse();
}
}
Este test confirma dos cosas importantes: el salt aleatorio hace que los hashes sean diferentes, y matches() aún reconoce correctamente la contraseña original aunque los hashes sean diferentes.
Nota: bcrypt tiene un límite de longitud de entrada de 72 bytes. Si la contraseña es más larga, la parte que excede se descarta silenciosamente. Para contraseñas normales (menos de 72 caracteres) no hay problema, pero si permites frases de contraseña muy largas, considera argon2.
5. Usar argon2 — la opción más moderna
Argon2 ganó la Password Hashing Competition en 2015 y es recomendado por OWASP como la opción preferida hoy en día gracias a su mejor resistencia contra ataques GPU-cracking (memory-hard).
5.1. Dependencia
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.78.1</version>
</dependency>
Spring Security usa Bouncy Castle como proveedor para Argon2PasswordEncoder, por lo que necesitas agregar esta dependencia.
5.2. Declarar el bean
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// saltLength=16, hashLength=32, parallelism=1, memory=1<<16 (64MB), iterations=3
return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
}
}
El método factory defaultsForSpringSecurity_v5_8() devuelve una configuración de parámetros que el equipo de Spring Security ha verificado que es segura para la mayoría de aplicaciones, no necesitas calcular memory/iterations por tu cuenta.
5.3. Comparar salida
$argon2id$v=19$m=65536,t=3,p=1$c29tZXNhbHQ$5t1h3u7K3iC2rT0...
La estructura incluye la variante (argon2id), versión, parámetro memory (m), iteraciones (t), paralelismo (p), y luego salt y hash — más transparente que bcrypt porque expone claramente el costo.
5.4. Test comparando dos encoders
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat;
class Argon2PasswordEncoderTest {
private final Argon2PasswordEncoder encoder =
Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
@Test
void shouldVerifyCorrectPassword() {
String hash = encoder.encode("MyP@ssw0rd123");
assertThat(encoder.matches("MyP@ssw0rd123", hash)).isTrue();
assertThat(encoder.matches("wrong", hash)).isFalse();
}
}
6. Soportar múltiples encoders con DelegatingPasswordEncoder
En realidad, tu sistema podría necesitar cambiar de algoritmo con el tiempo (por ejemplo, migrar de bcrypt a argon2) sin romper los hashes antiguos. Spring Security proporciona una solución lista para esto.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
createDelegatingPasswordEncoder() crea un encoder que sabe leer prefijos como {bcrypt}, {argon2}, {noop}… en la cadena hash para seleccionar el algoritmo correcto para decodificar. Por defecto, usa bcrypt para encodear contraseñas nuevas, pero aún así matches() funciona correctamente con hashes antiguos creados con otro algoritmo.
{bcrypt}$2a$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW
{argon2}$argon2id$v=19$m=65536,t=3,p=1$...
Gracias a este prefijo, puedes migrar gradualmente: usuarios antiguos aún pueden iniciar sesión, y nuevos usuarios o cambios de contraseña se codificarán con el algoritmo más reciente.
7. Comparación rápida entre bcrypt y argon2
| Criterio | bcrypt | argon2 |
|---|---|---|
| Año de lanzamiento | 1999 | 2015 (ganador PHC) |
| Resistencia GPU/ASIC | Buena | Mejor (memory-hard) |
| Límite de entrada | 72 bytes | Sin límite práctico |
| Adopción | Muy alta, soportado en todas partes | Cada vez más común |
| Recomendación OWASP | Opción 2 | Opción 1 |
Si el proyecto es nuevo y no está vinculado a sistemas heredados, elige argon2id (la variante argon2id es la predeterminada en Spring Security, balanceando resistencia contra ataques de canal lateral y ataques GPU). Si tu sistema ya usa bcrypt de forma estable durante años, no es necesario migrar de inmediato — aumentar el cost factor de bcrypt a 12–14 sigue siendo suficientemente seguro para la mayoría de aplicaciones.
8. Errores comunes
- Implementar salt + SHA-256 por tu cuenta: como se mencionó en la sección 2, sin work factor sigue siendo débil contra ataques de fuerza bruta modernos.
- Usar un cost factor demasiado bajo:
BCryptPasswordEncoder()sin parámetro usa cost 10 por defecto, pero si lo reduces a 4–5 para “ir más rápido” en tests, es fácil olvidar cambiarlo en producción. - Comparar hashes con
String.equals(): nunca compares cadenas de hash manualmente — siempre usamatches()dePasswordEncoder, ya que estas librerías hacen comparaciones en tiempo constante, evitando timing attacks. - Guardar salt en una columna separada de la BD: no es necesario, tanto bcrypt como argon2 incrustan el salt en la cadena de hash output.
- No limitar la velocidad de intentos de login: sin importar cuán fuerte sea el hash, sin rate limiting o account lockout, los ataques de fuerza bruta online siguen siendo viables.
Nota: Nunca uses el encoder
{noop}(plaintext) en producción. Solo debe aparecer en tests o demostraciones locales.
9. Conclusión
Hashear contraseñas correctamente significa usar un algoritmo diseñado específicamente para esto — bcrypt o argon2 — en lugar de funciones hash rápidas como SHA-256. Spring Security lo hace simple: solo declara un bean PasswordEncoder, llama a encode() al guardar y matches() al autenticar, sin necesidad de gestionar salt manualmente. Si necesitas migrar flexiblemente entre algoritmos, DelegatingPasswordEncoder es la opción segura y compatible hacia atrás. Ya sea que elijas bcrypt o argon2, recuerda que un hash fuerte es solo una capa de defensa — combina rate limiting y MFA para crear un sistema de autenticación verdaderamente seguro.