Validación de contraseñas en Laravel 13
Las contraseñas son el campo de formulario más sensible. Un mínimo de caracteres no basta – las contraseñas necesitan complejidad, verificación contra bases de datos filtradas y confirmación del usuario. Laravel ofrece el builder Password:: que encadena requisitos de forma legible, Password::defaults() para centralizar las reglas, confirmed para verificar que el usuario la escribió dos veces y current_password para confirmar la contraseña actual antes de un cambio. Este artículo cubre todo el flujo de validación de contraseñas.
Para los conceptos generales de validación, consulta la guía de inicio rápido.
El builder Password::
El punto de entrada es Password::min(), que establece la longitud mínima y devuelve un builder encadenable:
use Illuminate\Validation\Rules\Password;
$request->validate([
'password' => ['required', 'confirmed', Password::min(8)],
]);
Los métodos disponibles para complejidad:
// Al menos una letra (mayúscula o minúscula)
Password::min(8)->letters()
// Al menos una mayúscula Y una minúscula
Password::min(8)->mixedCase()
// Al menos un dígito
Password::min(8)->numbers()
// Al menos un carácter especial (!@#$%^& etc.)
Password::min(8)->symbols()
Se pueden encadenar todos:
Password::min(8)
->letters()
->mixedCase()
->numbers()
->symbols()
Esta configuración exige al menos 8 caracteres, con al menos una letra minúscula, una mayúscula, un número y un símbolo. Es el nivel de complejidad habitual para aplicaciones que manejan datos sensibles.
La longitud mínima de la contraseña se define a través de Password::min(). No hace falta añadir la regla min:8 como string por separado – el builder genera internamente la regla min:N. Si se necesita también una longitud máxima (poco habitual en contraseñas, pero requerido por algunos estándares), se añade max:128 como regla separada en el array:
'password' => ['required', 'max:128', Password::min(8)->letters()->numbers()],
Un detalle sobre mixedCase(): exige una mayúscula y una minúscula usando las propiedades Unicode \p{Lu} y \p{Ll}, que abarcan cualquier escritura con distinción de mayúsculas (latín, griego, cirílico, etc.). Para idiomas cuya escritura no tiene mayúsculas ni minúsculas (chino, árabe, hindi), mixedCase() no tiene sentido porque no hay caracteres que cumplan el requisito. En esos casos, letters() (que acepta cualquier carácter Unicode que sea una letra, vía \pL) es más apropiado.
Contraseñas comprometidas: uncompromised()
El método uncompromised() verifica que la contraseña no haya aparecido en filtraciones de datos públicas a través del servicio Have I Been Pwned:
Password::min(8)
->letters()
->mixedCase()
->numbers()
->uncompromised()
Laravel no envía la contraseña al servicio. Usa el modelo de k-Anonymity: calcula el hash SHA-1 de la contraseña, envía solo los primeros 5 caracteres del hash, recibe una lista de hashes que coinciden con ese prefijo y compara localmente. La contraseña nunca sale del servidor.
Por defecto, una contraseña que haya aparecido al menos una vez en una filtración se considera comprometida. Para ajustar el umbral:
// Solo rechazar si aparece más de 3 veces en filtraciones
Password::min(8)->uncompromised(3)
Por defecto basta con una aparición para fallar: internamente la comprobación es $count > $threshold con $threshold = 0, así que uncompromised() sin argumento rechaza cualquier resultado positivo en HIBP. Subir el umbral relaja la regla y solo conviene cuando las quejas de usuarios con contraseñas «fuertes en lo formal, pero presentes en filtraciones antiguas» pesan más que el riesgo. Para registro nuevo lo razonable es dejar el default; un valor mayor (3, 5) tiene sentido en endpoints donde la fricción importa y un rechazo molesto cuesta caro.
uncompromised() en tests y CI: la verificación hace una petición HTTPS a la API de Have I Been Pwned. En tests automatizados, esto añade latencia y puede fallar sin conexión a internet. Dos formas de manejarlo:
// Opción 1: en AppServiceProvider, no usar uncompromised en tests
Password::defaults(function () {
$regla = Password::min(8)->letters()->mixedCase()->numbers();
return app()->isProduction()
? $regla->uncompromised()
: $regla;
});
// Opción 2: mockear la petición HTTP en el test
Http::fake([
'api.pwnedpasswords.com/*' => Http::response('', 200),
]);
La primera opción es más simple y la que recomienda la documentación de Laravel. La segunda permite testear el flujo completo incluyendo la lógica de contraseñas comprometidas.
Password::defaults() – reglas centralizadas
En una aplicación con múltiples formularios que incluyen contraseñas (registro, cambio de contraseña, invitación, reset), repetir las reglas en cada Form Request es propenso a inconsistencias. Password::defaults() centraliza la configuración:
// En AppServiceProvider::boot()
use Illuminate\Validation\Rules\Password;
public function boot(): void
{
Password::defaults(function () {
$regla = Password::min(10)
->letters()
->mixedCase()
->numbers()
->symbols();
return app()->isProduction()
? $regla->uncompromised()
: $regla;
});
}
En cualquier Form Request o controlador:
$request->validate([
'password' => ['required', 'confirmed', Password::defaults()],
]);
Password::defaults() sin argumentos devuelve la configuración definida en el service provider. Cambiar los requisitos de contraseña en toda la aplicación requiere modificar un solo lugar.
Un punto importante: Password::defaults() no incluye required ni confirmed. Estas reglas se añaden por separado porque dependen del contexto – en un formulario de cambio de contraseña, la contraseña puede ser nullable (si el usuario no quiere cambiarla), y en un reset no necesita confirmed.
Añadir reglas personalizadas a defaults
El método rules() permite incorporar reglas adicionales al builder:
use App\Rules\SinPalabrasComunes;
Password::defaults(function () {
return Password::min(10)
->letters()
->numbers()
->rules([new SinPalabrasComunes]);
});
Las reglas pasadas a rules() se evalúan junto con las del builder. Esto permite integrar reglas personalizadas (como verificar contra un diccionario de contraseñas comunes o aplicar un algoritmo de fortaleza como zxcvbn) sin romper la cadena del builder.
La regla confirmed
La regla confirmed verifica que exista un campo de confirmación con el nombre {campo}_confirmation y que su valor coincida:
$request->validate([
'password' => ['required', 'confirmed', Password::min(8)],
]);
El formulario debe incluir un campo password_confirmation:
<input type="password" name="password">
<input type="password" name="password_confirmation">
Si el campo de confirmación tiene un nombre distinto, se puede especificar:
'password' => ['required', 'confirmed:repetir_password', Password::min(8)],
Esto espera un campo repetir_password en el input.
Un error habitual es poner confirmed dentro de la cadena del builder Password – por ejemplo Password::min(8)->rules('confirmed'). Esto funciona técnicamente, pero mezcla reglas de campo con reglas de contraseña de forma confusa. Mantener confirmed como regla separada en el array es más claro.
Otro error: el campo de confirmación no se envía. Si el formulario usa JavaScript para validar en el cliente y el campo de confirmación se genera dinámicamente, es posible que no se incluya en el submit. La regla confirmed falla silenciosamente cuando el campo _confirmation no existe – el mensaje dice “la contraseña no coincide” cuando en realidad el campo falta.
La regla current_password
Para formularios de cambio de contraseña donde el usuario debe confirmar su contraseña actual antes de establecer una nueva:
$request->validate([
'password_actual' => 'required|current_password',
'password_nueva' => ['required', 'confirmed', Password::defaults()],
]);
current_password usa Hash::check() para comparar el valor enviado con el hash almacenado del usuario autenticado. Si la aplicación usa un guard diferente al por defecto:
'password_actual' => 'required|current_password:admin'
El parámetro indica el guard de autenticación, no el nombre del campo.
Un flujo completo de cambio de contraseña en un Form Request:
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class CambiarPasswordRequest extends FormRequest
{
public function rules(): array
{
return [
'password_actual' => 'required|current_password',
'password_nueva' => ['required', 'confirmed', Password::defaults()],
];
}
public function messages(): array
{
return [
'password_actual.current_password' => 'La contraseña actual no es correcta.',
'password_nueva.confirmed' => 'Las contraseñas nuevas no coinciden.',
];
}
}
El controlador actualiza el hash:
public function cambiar(CambiarPasswordRequest $request)
{
$request->user()->update([
'password' => $request->validated()['password_nueva'],
]);
return back()->with('exito', 'Contraseña actualizada.');
}
En Laravel 11/12/13 el modelo User ya declara el cast 'password' => 'hashed', así que la asignación pasa por bcrypt automáticamente. No llames a Hash::make() además del cast: el doble hashing rompe el login porque la contraseña guardada deja de coincidir con la introducida. Si por alguna razón quitas el cast, vuelve a Hash::make($request->validated()['password_nueva']); pero el camino canónico hoy es confiar en el cast.
Contraseñas con regex
Antes del builder Password::, la forma habitual de exigir complejidad era con regex:
// Enfoque antiguo – funcional pero difícil de leer y mantener
'password' => [
'required',
'string',
'min:8',
'regex:/[a-z]/',
'regex:/[A-Z]/',
'regex:/[0-9]/',
'regex:/[@$!%*#?&]/',
],
El builder Password:: reemplaza estos regex con métodos legibles:
// Equivalente moderno
'password' => [
'required',
Password::min(8)
->letters()
->mixedCase()
->numbers()
->symbols(),
],
Ambos enfoques producen el mismo resultado, pero el builder genera mensajes de error específicos para cada requisito que falla (“La contraseña debe contener al menos un número”), mientras que el regex produce un mensaje genérico (“El formato de la contraseña no es válido”). Esta diferencia en la experiencia del usuario es significativa – con regex, el usuario no sabe si le falta una mayúscula, un número o un símbolo y tiene que adivinar. Con el builder, cada requisito faltante se lista por separado.
Hay un caso donde el regex sigue siendo necesario: cuando se requieren caracteres específicos que no encajan en las categorías del builder. Por ejemplo, prohibir espacios o exigir que la contraseña no contenga el nombre del usuario. Para estos casos, combinar el builder con un regex o una regla personalizada:
'password' => [
'required',
Password::min(8)->letters()->numbers(),
'regex:/^\S+$/', // Sin espacios
],
Diferentes reglas por contexto
No todos los formularios de contraseña necesitan las mismas reglas:
Registro: máxima complejidad. El usuario crea una contraseña nueva.
'password' => ['required', 'confirmed', Password::defaults()],
Cambio de contraseña: la misma complejidad, más verificación de la contraseña actual.
'password_actual' => 'required|current_password',
'password_nueva' => ['required', 'confirmed', Password::defaults()],
Reset por email: el usuario ya verificó su identidad por email, así que current_password no aplica, pero confirmed sí.
'password' => ['required', 'confirmed', Password::defaults()],
Invitación (admin crea cuenta): el administrador establece una contraseña temporal. Puede tener requisitos reducidos si se fuerza el cambio en el primer login.
'password' => ['required', Password::min(8)],
Password::defaults() cubre los tres primeros casos. El cuarto caso usa una configuración más relajada porque la contraseña es temporal. Si la aplicación fuerza el cambio en el primer login, la contraseña temporal ni siquiera necesita confirmed – el administrador la establece directamente y el usuario la reemplaza al entrar por primera vez.
Mensajes personalizados
Los mensajes del builder Password:: se pueden personalizar en el archivo de idioma lang/es/validation.php. Las claves relevantes están dentro del array password:
// lang/es/validation.php
'password' => [
'letters' => 'La :attribute debe contener al menos una letra.',
'mixed' => 'La :attribute debe contener al menos una mayúscula y una minúscula.',
'numbers' => 'La :attribute debe contener al menos un número.',
'symbols' => 'La :attribute debe contener al menos un carácter especial.',
'uncompromised' => 'La :attribute ha aparecido en una filtración de datos. Elige otra.',
],
Cada método del builder (letters(), mixedCase(), numbers(), symbols(), uncompromised()) tiene su propia clave de mensaje. Si la contraseña falla varios requisitos, el usuario recibe un mensaje por cada uno, no un mensaje genérico.
En un Form Request, los mensajes se pueden sobreescribir con el método messages():
public function messages(): array
{
return [
'password.min' => 'La contraseña necesita al menos :min caracteres.',
];
}
Errores frecuentes
Password::defaults() sin required – el builder define complejidad, no presencia. Si el campo llega vacío, las reglas de complejidad no se evalúan (un campo vacío no necesita tener 8 caracteres). Añadir required como regla separada.
confirmed dentro del builder – poner Password::min(8)->rules('confirmed') funciona pero es confuso. Mantener confirmed fuera del builder como regla independiente.
uncompromised() en tests sin internet – la verificación llama a la API de Have I Been Pwned. En CI sin acceso externo, el test falla con timeout. Usar Password::defaults() con lógica condicional para omitir uncompromised() fuera de producción.
Campo _confirmation ausente – si el formulario no incluye password_confirmation, la regla confirmed falla con “las contraseñas no coinciden” en vez de “falta el campo de confirmación”. El mensaje confunde al usuario. Verificar que el HTML incluya el campo.
current_password con guard incorrecto – en aplicaciones con múltiples guards (web, admin, api), current_password usa el guard por defecto. Si el usuario está autenticado con otro guard, la verificación falla. Pasar el guard como parámetro: current_password:admin.
Regex vs builder para mensajes – los regex producen un solo mensaje genérico cuando fallan. El builder produce un mensaje por cada requisito. Si la aplicación necesita indicar al usuario exactamente qué le falta a la contraseña (una mayúscula, un número, un símbolo), el builder es mejor que el regex.
Hash::make() doble – si el modelo tiene el cast hashed en el atributo password y además se llama a Hash::make() en el controlador, la contraseña se hashea dos veces. El resultado es un hash de un hash, y el usuario no puede iniciar sesión. Usar uno u otro, no ambos.
Longitud mínima demasiado corta – Password::min(6) es técnicamente válido pero insuficiente para la mayoría de aplicaciones. NIST recomienda un mínimo de 8 caracteres; muchos estándares de seguridad exigen 10 o más. Usar Password::min(8) como mínimo absoluto, y preferir 10-12 para aplicaciones con datos sensibles.
Para la lista completa de reglas de validación, consulta la referencia de reglas. Los mensajes de error y su personalización se cubren en mensajes de error.