wppw.org
enrues
Laravel 13.x · Docs

Validación de contraseñas en Laravel 13

Actualizado 18 de mayo de 2026 Laravel 13.x 30 min lectura
En esta página 21

La cadena 123456 pasa la regla required|string sin problema: es texto y está presente. Pero no es la clase de contraseña que debería entrar en la base de datos. Laravel expone el objeto Illuminate\Validation\Rules\Password para imponer complejidad real (longitud mínima, mezcla de mayúsculas y minúsculas, dígitos, símbolos, comprobación contra filtraciones públicas) junto con las reglas clásicas confirmed, same y current_password. Aquí van el builder completo, los flujos típicos de registro, cambio y reseteo, los problemas que aparecen en producción y cómo probarlo sin depender de servicios externos.

Para los conceptos generales de validación, ver la guía de inicio.

El builder Password::

El punto de entrada es Password::min(), que fija la longitud mínima y devuelve un builder encadenable:

use Illuminate\Validation\Rules\Password;

$request->validate([
    'password' => ['required', 'confirmed', Password::min(8)],
]);

Password::min(8) ya aplica la longitud mínima como parte del propio rule object, así que añadir min:8 por separado es redundante. Lo que sí hay que añadir aparte son required (presencia del campo) y confirmed (verificación del campo de confirmación): el builder no las contempla porque dependen del contexto.

Métodos de complejidad

Cada método añade un requisito independiente y cada requisito fallado produce su propio mensaje de error. El usuario ve exactamente qué le falta en lugar de un genérico “contraseña débil”:

// 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()

Los métodos se combinan en cualquier orden:

// Panel interno de administración: cadena estricta
Password::min(12)->mixedCase()->numbers()->symbols()

// Registro público: más permisivo
Password::min(8)->letters()->numbers()

Sobre mixedCase(): en la implementación actual de Laravel detecta mayúscula y minúscula con propiedades Unicode equivalentes a \p{Lu} y \p{Ll}, que cubren cualquier escritura con distinción de caja (latín, griego, cirílico). El detalle interno conviene verificarlo en el source si el equipo construye lógica multi-script sobre este supuesto. Para idiomas sin mayúsculas (chino, árabe, hindi) la regla no tiene sentido: letters() (cualquier letra Unicode vía \pL) es más apropiada.

NIST SP 800-63B sitúa la longitud como factor principal de fortaleza. Una passphrase de 20 caracteres es más segura que 8 caracteres con símbolos obligatorios. Por eso muchos equipos sustituyen symbols() por más longitud: Password::min(12)->letters()->numbers()->uncompromised() es un setup razonable para producción.

uncompromised(): contraseñas comprometidas

Password::min(8)->uncompromised()

Laravel calcula el SHA-1 de la contraseña, envía los primeros 5 caracteres del hash a la API de haveibeenpwned.com y recibe la lista de hashes que comparten ese prefijo. La comparación completa ocurre localmente: la contraseña nunca sale del servidor. Es el modelo de k-Anonymity.

Por defecto, la regla falla si la contraseña aparece al menos una vez en cualquier filtración. El umbral se puede relajar:

// Rechazar solo si aparece 3 veces o más en filtraciones
Password::min(8)->uncompromised(3);

Con uncompromised(3), la regla acepta contraseñas que aparezcan una o dos veces y solo rechaza las que aparezcan tres veces o más; con el default sin argumento se rechaza ante cualquier aparición. Subir el umbral a 3 o 5 relaja la regla y solo tiene sentido si la fricción del rechazo cuesta más que el riesgo (por ejemplo, flujos de migración masiva desde sistemas antiguos donde muchos usuarios reutilizan contraseñas conocidas).

La llamada hace una petición HTTPS, lo que añade latencia a cada validación. Hay un detalle de seguridad poco evidente verificable en el source actual de NotPwnedVerifier: si la API es inalcanzable, el verifier captura la excepción internamente, la reporta a los logs y devuelve “no comprometida”. La validación pasa silenciosamente. Una red caída equivale a desactivar uncompromised() sin aviso, lo que degrada la garantía sin que el flujo del usuario lo refleje. En tests y CI esto se gestiona desactivando el método mediante Password::defaults() (ver más abajo) o mockeando la petición:

Http::fake([
    'api.pwnedpasswords.com/*' => Http::response('', 200),
]);

Cadena completa y longitud máxima

Todos los métodos juntos para máxima exigencia:

'password' => [
    'required', 'confirmed', 'max:72',
    Password::min(8)
        ->letters()
        ->mixedCase()
        ->numbers()
        ->symbols()
        ->uncompromised(),
],

max:72 es una regla aparte, no parte del builder. El motivo está en el hash: Bcrypt (driver por defecto de Laravel) trunca la entrada a 72 bytes antes de calcular el hash. Dos contraseñas que solo difieren a partir del byte 72 acaban con el mismo hash, así que el usuario puede “cambiar” su contraseña sin efecto real. Si se usa Argon2id (HASH_DRIVER=argon2id) el truncamiento desaparece para las contraseñas nuevas hasheadas con ese driver; sin embargo, las contraseñas almacenadas antes del cambio siguen con hash bcrypt hasta que cada usuario haga login (lo que dispara el rehash automático, si está habilitado) o pase por cambio/reseteo. Mientras conviven hashes de los dos drivers en la tabla, mantener max:72 evita exponer el truncamiento para la parte bcrypt de la base. En entornos solo-argon2id desde día uno, max:255 cubre el caso de bloquear entradas absurdamente largas.

Regex cuando el builder no llega

Para requisitos que no caben en los métodos del builder, se combina con la regla regex. Por ejemplo, prohibir espacios o exigir al menos dos dígitos:

'password' => [
    'required',
    'confirmed',
    'max:72',
    Password::min(8)->letters()->numbers(),
    'regex:/^\S+$/',      // sin espacios en blanco
    'regex:/\d.*\d/',     // al menos dos dígitos
],

Si el patrón contiene una barra vertical |, hay que pasar las reglas como array (no como string con pipes separadores), o el parser de reglas la confunde con el separador. El builder produce mensajes específicos por cada requisito fallado, mientras que regex da un mensaje genérico (“el formato no es válido”), así que conviene reservar regex para casos que el builder no cubre y dejarle al builder lo que sí cubre. Para validaciones más elaboradas (no contener nombre del usuario, no parecerse a contraseñas previas, comprobaciones contextuales), usar reglas personalizadas en lugar de regex anidados.

La regla confirmed

confirmed exige que el request incluya un campo {campo}_confirmation con el mismo valor:

$request->validate([
    'password' => ['required', 'confirmed', Password::min(8)],
]);

El formulario:

<input type="password" name="password">
<input type="password" name="password_confirmation">

En la implementación actual el error de no coincidencia se asocia al campo principal password, no al campo de confirmación. En Blade:

@error('password')
    <span>{{ $message }}</span>
@enderror

Si el campo de confirmación tiene otro nombre, se indica como parámetro:

'password' => ['required', 'confirmed:repetir_password'],

Ahora Laravel busca un campo repetir_password. Un detalle silencioso a tener en cuenta: si el campo de confirmación no existe en absoluto en el request, en la implementación actual la regla confirmed falla como si los valores no coincidieran. Si el formulario genera el segundo input por JavaScript y a veces no se envía, el mensaje “las contraseñas no coinciden” confunde al usuario sobre la causa real (la ausencia del campo).

confirmed vs same

same:otroCampo también compara dos campos, pero sin el sufijo automático _confirmation:

$request->validate([
    'password'       => ['required', Password::min(8)],
    'password_check' => ['required', 'same:password'],
]);

La diferencia clave es a qué campo se asocia el error:

// confirmed: error en el campo password
'password' => ['required', 'confirmed']

// same: error en el campo password_check
'password_check' => ['required', 'same:password']

Con confirmed basta una regla en el campo principal y Laravel sabe que el segundo input se llama password_confirmation. Con same se necesitan reglas en ambos campos y el error vive en el segundo. Para contraseñas el estándar es confirmed. same resulta útil en otros escenarios de coincidencia (confirmación de email, repetir PIN).

current_password: confirmar la contraseña actual

Antes de cambiar la contraseña hay que comprobar que el usuario conoce la actual. current_password compara el valor enviado con el hash del usuario autenticado:

$request->validate([
    'current_password' => ['required', 'current_password'],
    'password'         => ['required', 'confirmed', Password::min(8)->mixedCase()->numbers()],
]);

Si la aplicación usa varios guards, se indica cuál:

'current_password' => ['required', 'current_password:admin'],

El parámetro de current_password es el nombre del guard, no el del campo. Internamente la regla llama a Hash::check() con el hash del usuario. Si el usuario no está autenticado en el guard indicado, la regla falla la validación (en la implementación actual sin lanzar excepción). De cualquier modo, el contrato razonable es proteger la ruta con el middleware auth: la regla no debería ejecutarse para usuarios anónimos, y delegar la verificación de presencia al middleware evita depender de los detalles internos del rule.

Más allá del cambio de contraseña, sirve también para confirmar la identidad antes de acciones sensibles (cambiar email, eliminar cuenta):

$request->validate([
    'current_password' => ['required', 'current_password'],
    'email'            => ['required', 'email', Rule::unique('users')->ignore($request->user())],
]);

Password::defaults(): configuración global

Repetir Password::min(8)->mixedCase()->numbers() en cada Form Request es propenso a divergencias entre formularios. Password::defaults() centraliza la configuración una sola vez:

use Illuminate\Validation\Rules\Password;

// En AppServiceProvider::boot()
public function boot(): void
{
    Password::defaults(function () {
        $regla = Password::min(8);

        return app()->isProduction()
            ? $regla->mixedCase()->numbers()->uncompromised()
            : $regla;
    });
}

En producción se aplican reglas estrictas con comprobación de filtraciones. En desarrollo y tests solo longitud mínima, así contraseñas como password siguen siendo válidas en seeders y factories. El closure se ejecuta en cada llamada, así que el entorno se evalúa dinámicamente.

Para usar la configuración en una regla:

'password' => ['required', 'confirmed', Password::defaults()],

Password::defaults() sin argumentos devuelve la cadena producida por el closure. Para no depender de “qué pasa si nadie registra el closure”, la garantía se asegura con un test de comportamiento: una contraseña sabidamente débil debe fallar contra la regla devuelta por Password::defaults(). Si alguien quita mixedCase() o uncompromised() por accidente, el validator deja de rechazar password/123456 y el test cae antes que un usuario.

public function test_default_password_policy_rejects_weak_passwords(): void
{
    $validador = Validator::make(
        ['password' => 'password'],
        ['password' => Password::defaults()],
    );

    $this->assertTrue($validador->fails());
}

Un assert sobre la representación textual del objeto Password no funciona: la clase no implementa __toString() y un cast a string lanzaría un Error antes de las aserciones. Comprobar el comportamiento contra un validator real es además más resistente a refactors internos del rule.

Reglas adicionales sobre defaults

El método rules() añade reglas extra al builder:

use App\Rules\SinPalabrasComunes;

Password::defaults(function () {
    return Password::min(8)
        ->mixedCase()
        ->rules([new SinPalabrasComunes]);
});

Las reglas pasadas a rules() se evalúan junto con las del builder. Esto permite combinar políticas estándar (longitud, complejidad) con verificaciones personalizadas (diccionario de palabras prohibidas, algoritmos de fortaleza como zxcvbn) sin romper la cadena.

2FA: la validación de la contraseña no cambia

Si la aplicación usa autenticación de dos factores (con Laravel Fortify o una implementación propia), las reglas de la contraseña siguen aplicándose tal cual. 2FA es una capa adicional después de comprobar la contraseña, no la sustituye. El OTP o TOTP se valida como otro campo del request, normalmente con su propia regla digits:6 o similar:

$request->validate([
    'password' => ['required', Password::defaults()],
    'codigo'   => ['required', 'digits:6'],
]);

Una tentación frecuente es relajar la complejidad de la contraseña al añadir 2FA (“si hay segundo factor, una contraseña simple basta”). En la práctica es mejor mantener ambos factores fuertes: si el dispositivo del segundo factor se pierde y se usa un mecanismo de recuperación, lo único que queda en pie es la contraseña.

Generación de contraseñas seguras: Str::password()

Cuando el sistema genera la contraseña en lugar del usuario (invitaciones, accesos temporales, restablecimientos administrativos), Str::password() devuelve una cadena aleatoria que cumple los requisitos típicos:

use Illuminate\Support\Str;

$passwordTemporal = Str::password(16);
// Ejemplo: "k3$mP9vL!xQ2nR7w"

La longitud por defecto es 32. Los parámetros controlan la composición:

Str::password(
    length: 20,
    letters: true,
    numbers: true,
    symbols: true,
);

La implementación actual de Str::password() produce cadenas que incluyen caracteres de cada grupo habilitado, pero los detalles del muestreo no forman parte de un contrato público estable. Cuando la política Password añade requisitos estrictos (por ejemplo uncompromised, donde una cadena válida formalmente puede aún colisionar con una entrada de HIBP), pasar la salida por las mismas reglas del Form Request es la única garantía de fin a fin:

$intentos = 0;

do {
    $passwordTemporal = Str::password(20);
} while (
    Validator::make(
        ['p' => $passwordTemporal],
        ['p' => Password::defaults()],
    )->fails()
    && ++$intentos < 10
);

El límite de intentos evita un bucle infinito si en algún momento los defaults se vuelven incompatibles con la salida de Str::password() (por ejemplo si alguien añade un Password::min(40) cuando la generación pide longitud 20). En la práctica Str::password(20) con letras + números + símbolos pasa los defaults estándar en la primera iteración.

El flujo habitual: un administrador crea un usuario, el sistema genera la contraseña, la envía por correo y obliga al cambio en el primer login. Para forzar ese cambio, basta una columna booleana must_change_password en users y un middleware (excluyendo las rutas de logout y de cambio de contraseña, o se entra en bucle de redirect) que redirija al formulario de cambio mientras el flag siga activo.

Prohibir coincidencia con email o nombre

password igual a [email protected] para el usuario [email protected] es una mala idea. El builder no cubre esta verificación directamente, pero se añade en el hook after() de un Form Request:

public function after(): array
{
    return [
        function ($validator) {
            $password = $this->input('password');
            $email    = $this->input('email') ?? $this->user()?->email;

            if ($password && $email && false !== stripos($password, explode('@', $email)[0])) {
                $validator->errors()->add(
                    'password',
                    'La contraseña no debe contener el nombre del correo.'
                );
            }
        },
    ];
}

Para reutilizar la lógica entre formularios, extraerla a una regla personalizada y registrarla en el array de reglas del campo password. La misma idea sirve para prohibir que la contraseña contenga el nombre del usuario, el teléfono o cualquier dato público asociado.

Políticas por rol o por contexto

Diferentes usuarios suelen requerir diferentes niveles de exigencia. Los administradores soportan más fricción a cambio de más seguridad:

class StoreUserRequest extends FormRequest
{
    public function rules(): array
    {
        $reglaPassword = 'admin' === $this->input('role')
            ? Password::min(12)->mixedCase()->numbers()->symbols()->uncompromised()
            : Password::defaults();

        return [
            'name'     => ['required', 'string', 'max:100'],
            'email'    => ['required', 'email', 'unique:users'],
            'role'     => ['required', 'in:user,editor,admin'],
            'password' => ['required', 'confirmed', $reglaPassword],
        ];
    }
}

La alternativa es un Form Request por rol. Si solo cambia la contraseña, una condición en rules() es más simple. Si cambian varios campos (autorizaciones, validaciones cruzadas), conviene separar las clases.

OAuth y Socialite: cuando no hay contraseña

Los usuarios que se registran a través de Google, GitHub u otro proveedor OAuth no envían contraseña. La columna password en users queda null. Esto requiere que la columna sea nullable: la migración por defecto de Laravel (create_users_table) define $table->string('password') sin nullable(), así que insertar null lanza un error de integridad. Antes de aceptar usuarios OAuth-only, alterar la columna (en Laravel 11+ el schema builder lo hace sin paquetes extra; en versiones anteriores hay que instalar doctrine/dbal antes):

Schema::table('users', function (Blueprint $table) {
    $table->string('password')->nullable()->change();
});

Con la columna nullable, el flujo de Socialite necesita dos comprobaciones. La primera: que el provider haya verificado el email. Cada provider expone la verificación de forma distinta y conviene encapsular la decisión por provider en lugar de leer una clave fija:

  • Google devuelve email y email_verified como campos separados en el ID token OIDC. La presencia de email no implica posesión: el flag email_verified es lo que distingue “email asociado a la cuenta” de “email cuya posesión Google comprobó”.
  • GitHub no incluye el flag en $socialUser->user. La verificación se consulta en el endpoint /user/emails con el token: cada entrada tiene primary y verified.
  • Facebook solo devuelve emails que el usuario aprobó, sin un flag análogo.
  • IdPs self-hosted o providers menores pueden devolver emails sin verificar.

La segunda comprobación: que la cuenta local no exista previamente con contraseña, para no entregar acceso sin pedir el secreto que la creó.

// Estrategia simple: Google. Para otros providers, sustituir esta línea
// por la consulta apropiada (GitHub: /user/emails, Facebook: confiar
// en la política del provider, etc.). Encapsular en un VerifierFor($provider).
$emailVerificado = $socialUser->user['email_verified'] ?? false;

if (! $socialUser->getEmail() || ! $emailVerificado) {
    abort(422, 'El proveedor OAuth no confirmó el email del usuario.');
}

$existente = User::where('email', $socialUser->getEmail())->first();

if ($existente && null !== $existente->password) {
    // Cuenta local previa: no autologin. Redirigir a un flujo de vinculación
    // que pida la contraseña actual antes de unir.
    return redirect()->route('account.link')->with('provider', $socialUser);
}

$user = User::firstOrCreate(
    ['email' => $socialUser->getEmail()],
    ['name' => $socialUser->getName(), 'password' => null],
);

Cuidado adicional con la normalización del email: Gmail trata [email protected] y [email protected] como la misma cuenta, pero User::where('email', ...) en SQL hace una comparación literal. Si dos usuarios entraron en momentos distintos con variantes del mismo email, la búsqueda los considera separados y la unificación de cuentas no funciona como se espera. Para apps con muchos usuarios Google conviene normalizar el email (lowercase + reglas específicas de Gmail) antes de almacenar y consultar.

Más adelante el usuario puede querer establecer una contraseña para iniciar sesión sin OAuth. En ese formulario current_password no aplica (no hay contraseña previa), pero confirmed y los requisitos de complejidad sí:

public function rules(): array
{
    $reglas = [
        'password' => ['required', 'confirmed', 'max:72', Password::defaults()],
    ];

    if (null !== $this->user()->password) {
        $reglas['current_password'] = ['required', 'current_password'];
    }

    return $reglas;
}

La condición detecta si el usuario ya tiene contraseña y solo entonces exige la actual. El mismo patrón sirve cuando un usuario vinculó después una cuenta OAuth y quiere “promocionar” su acceso a contraseña + 2FA.

Ejemplo completo: registro

Form Request, controlador y formulario de un registro con Password::defaults() configurado en el service provider:

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;

class RegisterRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name'     => ['required', 'string', 'max:60'],
            'email'    => ['required', 'email:rfc,dns', 'unique:users'],
            'password' => ['required', 'confirmed', 'max:72', Password::defaults()],
        ];
    }
}
class RegisterController extends Controller
{
    public function store(RegisterRequest $request): RedirectResponse
    {
        // El cast 'hashed' del modelo aplica Hash::make() con el driver configurado
        // (bcrypt por defecto, argon2id si HASH_DRIVER=argon2id).
        // No llamamos a Hash::make() aquí: el doble hash rompe el login.
        // El $fillable del modelo User (name, email, password) descarta
        // password_confirmation automáticamente; no hace falta excluirlo a mano.
        $user = User::create($request->validated());

        Auth::login($user);

        return to_route('dashboard');
    }
}
<form method="POST" action="/registro">
    @csrf

    <label for="password">Contraseña</label>
    <input type="password" name="password" id="password">
    @error('password')
        <span>{{ $message }}</span>
    @enderror

    <label for="password_confirmation">Repetir contraseña</label>
    <input type="password" name="password_confirmation" id="password_confirmation">

    <button type="submit">Crear cuenta</button>
</form>

El error de confirmed aparece bajo el campo password, no bajo password_confirmation. Poner @error('password_confirmation') no muestra nada útil.

Ejemplo completo: cambio de contraseña

Tres campos en el formulario: contraseña actual, nueva, repetición:

class ChangePasswordRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'current_password' => ['required', 'current_password'],
            'password'         => ['required', 'confirmed', 'max:72', Password::defaults()],
        ];
    }

    public function messages(): array
    {
        return [
            'current_password.current_password' => 'La contraseña actual no es correcta.',
        ];
    }
}
class PasswordController extends Controller
{
    public function update(ChangePasswordRequest $request): RedirectResponse
    {
        // password pasa por el cast 'hashed' al guardar.
        $request->user()->update([
            'password' => $request->validated('password'),
        ]);

        return back()->with('exito', 'Contraseña actualizada.');
    }
}
<form method="POST" action="/password">
    @csrf
    @method('PUT')

    <label for="current_password">Contraseña actual</label>
    <input type="password" name="current_password" id="current_password">
    @error('current_password')
        <span>{{ $message }}</span>
    @enderror

    <label for="password">Nueva contraseña</label>
    <input type="password" name="password" id="password">
    @error('password')
        <span>{{ $message }}</span>
    @enderror

    <label for="password_confirmation">Repetir nueva contraseña</label>
    <input type="password" name="password_confirmation" id="password_confirmation">

    <button type="submit">Cambiar contraseña</button>
</form>

Tres errores posibles: contraseña actual incorrecta (campo current_password), nueva contraseña débil o repetición no coincide (ambas en el campo password).

Ejemplo completo: reseteo por correo

En el flujo de “olvidé mi contraseña” el usuario ya demostró su identidad a través de un token que llegó por email. No hace falta current_password:

class ResetPasswordRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'token'    => ['required', 'string'],
            'email'    => ['required', 'email'],
            'password' => ['required', 'confirmed', 'max:72', Password::defaults()],
        ];
    }
}

El reseteo se implementa con Illuminate\Auth\Passwords\PasswordBroker; normalmente alcanza con los controladores que generan Laravel Breeze o Fortify. El Form Request anterior cubre la validación de los campos. Si la implementación es manual:

public function update(ResetPasswordRequest $request): RedirectResponse
{
    $status = Password::reset(
        $request->only('email', 'password', 'password_confirmation', 'token'),
        function ($user, $password) {
            $user->forceFill([
                'password' => Hash::make($password),
            ])->setRememberToken(Str::random(60));

            $user->save();

            event(new PasswordReset($user));
        }
    );

    return Password::PasswordReset === $status
        ? redirect()->route('login')->with('status', __($status))
        : back()->withErrors(['email' => __($status)]);
}

Dos líneas del closure suelen olvidarse y forman parte del patrón canónico de reseteo en la guía oficial:

  • setRememberToken(Str::random(60)) rota el “remember me” token del usuario. Cuando la app ofrece “recordar sesión” (Auth::attempt(..., $remember = true)), sin rotar el token una cookie remember-me robada sigue siendo válida después del reseteo. Mantener la línea siempre evita que un futuro cambio (un starter kit, un toggle de la UI) reintroduzca el problema sin que nadie revise el flujo de reset.
  • event(new PasswordReset($user)) dispara el evento Illuminate\Auth\Events\PasswordReset. Los starter kits (Breeze, Fortify, Jetstream) y listeners propios de auditoría/notificación se enganchan a él. Si ahora no hay ningún listener, la llamada es inerte; cuando se añada uno, funcionará sin tocar el flujo de reseteo. Por eso conviene dispararlo siempre.

Password::PasswordReset es la constante de estado del facade Illuminate\Support\Facades\Password (broker de reseteo), no del builder de reglas. El alias coincide pero apunta a clases diferentes.

Validación de contraseñas en API

Para endpoints JSON, las reglas son las mismas. Cambia el formato de la respuesta:

class ApiRegisterRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'email'    => ['required', 'email', 'unique:users'],
            'password' => ['required', 'confirmed', 'max:72', Password::defaults()],
        ];
    }
}

Si la contraseña no cumple los requisitos, el cliente recibe 422 con todos los errores agrupados por campo:

{
    "message": "El campo password debe contener al menos una letra mayúscula y una minúscula.",
    "errors": {
        "password": [
            "El campo password debe contener al menos una letra mayúscula y una minúscula.",
            "El campo password debe contener al menos un número."
        ]
    }
}

Cada requisito fallado de Password aparece como un elemento separado en errors.password. El frontend muestra la lista al usuario y este corrige todo lo que falte de una vez en lugar de descubrir los problemas uno por uno.

Para que la respuesta sea JSON, el camino documentado y fiable es la cabecera Accept: application/json. Existe también una rama con X-Requested-With: XMLHttpRequest, pero su precedencia frente al Accept depende de la negociación interna y conviene no apoyarse en ella. En aplicaciones híbridas, fijar Accept: application/json de forma explícita en el cliente HTTP que llama a la API quita ambigüedad.

Indicador de fortaleza en el frontend

La validación en el servidor es la fuente de verdad, pero al usuario le sirve ver los requisitos antes de enviar. Hay dos opciones razonables.

Precognition. Laravel pre-valida el formulario con las reglas reales del Form Request. En la ruta se aplica el middleware:

use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests;

Route::post('/registro', [RegisterController::class, 'store'])
    ->middleware([HandlePrecognitiveRequests::class]);

El middleware solo responde a peticiones con la cabecera Precognition: hasta que algo en el cliente emita esa petición, no pasa nada visible. Quien dispara los envíos (al perder el foco, con debounce, al cambiar el valor) es el paquete cliente oficial, que hay que instalar y conectar al formulario según el stack del frontend:

npm install laravel-precognition-vue   # o -react, -alpine, -svelte

Las definiciones de las reglas no se duplican: las del Form Request son las únicas. El cliente envía los datos parciales, el servidor responde con los errores que producirían esas mismas reglas y la librería los expone al formulario.

Indicador en cliente puro. Una barra de fortaleza con cuatro niveles (débil, regular, bueno, fuerte) escrita en JavaScript. Más simple de instalar pero las reglas se divergen entre frontend y backend con el tiempo. Sirve solo como pista visual para el usuario; la verificación real sigue en el servidor.

function fortaleza(password) {
    let puntos = 0;
    if (password.length >= 8) puntos++;
    if (password.length >= 12) puntos++;
    // \p{Ll}/\p{Lu} en Unicode para reflejar mixedCase() del servidor,
    // que usa \p{Lu} y \p{Ll} (latín, griego, cirílico).
    if (/\p{Ll}/u.test(password) && /\p{Lu}/u.test(password)) puntos++;
    if (/\d/.test(password)) puntos++;
    if (/[^\p{L}\d]/u.test(password)) puntos++;
    return ['Muy débil', 'Débil', 'Regular', 'Bueno', 'Fuerte', 'Excelente'][puntos];
}

Lo importante: no aceptar el formulario solo en base al cálculo cliente. Si JavaScript está desactivado, si el usuario manipula el código o si las reglas se modifican en el servidor sin actualizar el cliente, la única defensa real es la validación del Form Request.

Mensajes personalizados

Los mensajes por defecto vienen en inglés. Para sobreescribirlos por campo en un Form Request:

public function messages(): array
{
    return [
        'password.required'  => 'Introduce una contraseña.',
        'password.confirmed' => 'Las contraseñas no coinciden.',
        'password.min'       => 'La contraseña debe tener al menos :min caracteres.',
    ];
}

Los mensajes del builder (letters, mixed, numbers, symbols, uncompromised) se ajustan globalmente en lang/es/validation.php dentro del array password:

'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.',
],

La carpeta lang/ no existe en proyectos nuevos. Para crearla y obtener los archivos base:

php artisan lang:publish

Esto copia validation.php y otros archivos al directorio lang/<locale>/. Sobre la localización de mensajes en general ver Mensajes de error.

Historia de contraseñas

Laravel no incluye un mecanismo nativo para impedir reutilizar contraseñas antiguas, pero es fácil añadirlo con una tabla y una regla personalizada:

// Migración
Schema::create('password_histories', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('password');
    $table->timestamp('created_at');
});

Regla que comprueba contra las últimas N contraseñas guardadas:

use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Hash;

class SinReutilizacion implements ValidationRule
{
    public function __construct(private User $user, private int $cantidad = 5) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $hashesPrevios = $this->user->passwordHistories()
            ->latest()
            ->take($this->cantidad)
            ->pluck('password');

        foreach ($hashesPrevios as $hash) {
            if (Hash::check($value, $hash)) {
                $fail("No puedes reutilizar ninguna de tus últimas {$this->cantidad} contraseñas.");
                return;
            }
        }
    }
}

Conexión al Form Request:

'password' => [
    'required', 'confirmed', 'max:72',
    Password::defaults(),
    new SinReutilizacion($this->user(), 5),
],

El guardado del hash anterior va en un observer del modelo User o directamente en el controlador después de un cambio exitoso. Importante: la tabla password_histories guarda el hash, no la contraseña en claro. La comparación se hace con Hash::check() igual que en login.

Importación masiva sin confirmed

Al importar usuarios desde CSV o desde otra API, no hay un campo de confirmación: el flujo no es interactivo. Las reglas se reducen a longitud y complejidad:

use Illuminate\Support\Facades\Validator;

foreach ($filas as $fila) {
    $validador = Validator::make($fila, [
        'email'    => ['required', 'email', 'unique:users'],
        'password' => ['required', Password::min(8)->letters()->numbers()],
    ]);

    if ($validador->fails()) {
        $this->warn("Fila saltada: " . $validador->errors()->first());
        continue;
    }

    User::create($validador->validated());
}

confirmed no aplica (no hay segundo campo) y current_password tampoco (no hay sesión). Si el escenario contempla que el usuario reciba un correo con la contraseña temporal y se le obligue a cambiarla al entrar, las reglas pueden ser aún más laxas (Password::min(8)) y el rigor se desplaza al formulario de cambio que verá el usuario.

Validar un hash de contraseña

A veces hay que comparar una contraseña en claro contra un hash almacenado fuera del flujo normal de autenticación (rotar una API key, descargar datos sensibles). No hay una regla validation para esto: se hace con Hash::check():

use Illuminate\Support\Facades\Hash;

if (Hash::check($request->input('password'), $user->password)) {
    // La contraseña coincide con el hash almacenado
}

Esto no es una regla de validación, sino una comparación directa. Para reglas, current_password es el camino. Otra herramienta relacionada es Hash::needsRehash(): comprueba si el algoritmo del hash almacenado quedó obsoleto frente al actual.

En el login normal no hay que escribir nada: Auth::attempt() y los starter kits ya rehashean. La opción rehash_on_login en config/hashing.php controla este comportamiento; por defecto está activa en Laravel 11+ sin necesidad de publicar el archivo de config. En proyectos antiguos que tengan el config publicado, verificar que la clave esté en true. El snippet manual aplica cuando se necesita comprobar y rehashar fuera del flujo estándar de login, por ejemplo en una API que rota credenciales sin pasar por el guard. La precondición ineludible es haber validado primero con Hash::check; sin ella, cualquier valor del request reemplaza el hash almacenado:

$plain = $request->input('password');

if (Hash::check($plain, $user->password) && Hash::needsRehash($user->password)) {
    $user->update(['password' => $plain]);
}

Los usuarios que no entran a la aplicación durante meses mantienen el hash antiguo hasta su próximo login (el rehash necesita la contraseña en claro, así que no se puede hacer en background sobre la tabla).

Testing de validación de contraseñas

Los tests cubren tanto el rechazo de contraseñas débiles como la corrección del flujo completo:

public function test_registro_rechaza_password_debil(): void
{
    $response = $this->post('/registro', [
        'name'                  => 'Jane Doe',
        'email'                 => '[email protected]',
        'password'              => 'short',
        'password_confirmation' => 'short',
    ]);

    $response->assertSessionHasErrors('password');
}

public function test_password_y_confirmacion_deben_coincidir(): void
{
    $response = $this->post('/registro', [
        'name'                  => 'Jane Doe',
        'email'                 => '[email protected]',
        'password'              => 'SecurePass123!',
        'password_confirmation' => 'OtroPass456!',
    ]);

    $response->assertSessionHasErrors('password');
}

Si Password::defaults() incluye uncompromised(), los tests harán peticiones a haveibeenpwned.com en cada ejecución. En CI esto es problemático (lento, depende de internet, la API puede estar caída). Solución: sobreescribir Password::defaults() en el setUp() del TestCase base:

protected function setUp(): void
{
    parent::setUp();

    Password::defaults(fn () => Password::min(8));
}

Los tests funcionales corren con reglas relajadas y no tocan internet. Para cubrir explícitamente el comportamiento de uncompromised(), un test aislado con la marca de PHPUnit que solo se ejecuta manualmente o en una etapa especial del pipeline:

/** @group external */
public function test_password_filtrada_se_rechaza(): void
{
    Password::defaults(fn () => Password::min(8)->uncompromised());

    $response = $this->post('/registro', [
        'name'                  => 'Test',
        'email'                 => '[email protected]',
        'password'              => 'password',
        'password_confirmation' => 'password',
    ]);

    $response->assertSessionHasErrors('password');
}

Para el cambio de contraseña con verificación de la actual:

public function test_cambio_requiere_password_actual_correcto(): void
{
    $user = User::factory()->create();
    $this->actingAs($user);

    $response = $this->put('/password', [
        'current_password'      => 'wrong-password',
        'password'              => 'NewSecure123!',
        'password_confirmation' => 'NewSecure123!',
    ]);

    $response->assertSessionHasErrors('current_password');
}

public function test_cambio_password_exitoso(): void
{
    $user = User::factory()->create(['password' => 'OldPass123!']);
    $this->actingAs($user);

    $response = $this->put('/password', [
        'current_password'      => 'OldPass123!',
        'password'              => 'NewSecure456!',
        'password_confirmation' => 'NewSecure456!',
    ]);

    $response->assertSessionHasNoErrors();
    $this->assertTrue(Hash::check('NewSecure456!', $user->fresh()->password));
}

User::factory()->create(['password' => 'OldPass123!']) funciona porque el cast hashed del modelo se aplica también en factory.

Errores frecuentes

Hashear antes de validar. Llamar a Hash::make() sobre la contraseña antes de pasar las reglas hace que Password::min(8) evalúe la longitud del hash (~60 caracteres), no de la entrada original. Validar primero, hashear después (o confiar en el cast hashed del modelo, que es lo recomendado en Laravel 11/12/13).

Hash::make() encima del cast. El modelo User ya declara 'password' => 'hashed' en casts() desde Laravel 11. Si además se llama a Hash::make() en el controlador, la contraseña se hashea dos veces y el login deja de funcionar (el hash almacenado no coincide con el que produce el login). Una de las dos, no ambas.

@error('password_confirmation') en lugar de @error('password'). El error de confirmed se asocia al campo principal. Poner el @error bajo el campo de repetición no muestra nada.

Olvidar max:72 con bcrypt. Sin este límite, dos contraseñas que difieren a partir del byte 72 producen hashes idénticos. El usuario “cambia” su contraseña sin efecto real. La regla max:72 se añade aparte del builder.

APP_ENV mal puesto en staging o CI. El closure de Password::defaults() suele ramificar con app()->isProduction(). Si staging arranca como local o el contenedor de CI no recibe el .env correcto, las reglas estrictas no se aplican exactamente donde se prueban. El bug es invisible en code review (el código está bien), aparece solo al inspeccionar las variables del entorno. Revisar APP_ENV en cada deploy.

uncompromised() con cualquier respuesta no-200. No solo afecta a entornos sin internet (CI sin proxy, air-gapped). Cualquier fallo HTTP hace que NotPwnedVerifier capture la excepción, la reporte a logs y devuelva “no comprometida”: timeouts, 429 de rate-limit de HIBP cuando el servicio está saturado, respuestas 5xx, certificado TLS caducado, payload corrupto. En producción con internet pleno, durante un incidente de disponibilidad de HIBP, cualquier contraseña filtrada del mundo se acepta sin alarma. La mitigación práctica: instrumentar el canal de log para alertar sobre fallos del verifier, o sustituirlo por una implementación que falle ruidosamente en producción.

Falta del cast hashed en el modelo User. Sin el cast, la contraseña se guarda en texto plano. Verificar con php artisan model:show User: la sección Casts debe listar password: hashed. Si falta (porque el modelo se generó en una versión antigua o se sobreescribió), añadirlo en casts():

protected function casts(): array
{
    return [
        'email_verified_at' => 'datetime',
        'password'          => 'hashed',
    ];
}

same:password en vez de confirmed. Técnicamente funciona, pero asocia el error al campo de repetición. La regla confirmed deja el error en el campo principal, que es donde el usuario espera leerlo.

Longitud mínima demasiado corta. Password::min(6) es válido pero pobre. NIST recomienda 8 como suelo absoluto; la mayoría de marcos de seguridad pide 10 o más. Para datos sensibles, 12 con uncompromised() es una base sensata.

Para reglas relacionadas (unique, exists, valores condicionales) ver Reglas de validación y Validación condicional. Para personalizar reglas propias, Reglas personalizadas. El detalle de los Form Requests está en Form Request en Laravel.

¿Te ha resultado útil este artículo?

Reportar un error