Skip to content
Finowl

Laravel · Password breach checks

Validate breached passwords with a Laravel rule

NotBreachedPassword implements Laravel's ValidationRule interface, so you use it like any other rule object in a FormRequest or Validator::make call. The validate method sends the value to /v1/breaches/password through the Http::finowl() macro with a five second timeout, and calls $fail when the response says breached is true. The message uses the :attribute placeholder, so it reads naturally whatever the field is called.

Failures that are not the user's fault go through a single unavailable method. With failOpen set to true, a timeout, rejected key or rate limit is logged and the password is accepted. Pass false to the constructor for stricter flows such as admin accounts. A 400 is deliberately silent, because it only happens for empty passwords or ones longer than 4096 characters, which min and max rules already explain.

Steps

  1. 1

    Register the Http::finowl() macro and add the finowl entry to config/services.php.

  2. 2

    Create the rule with php artisan make:rule NotBreachedPassword and paste in the example.

  3. 3

    Add new NotBreachedPassword() after min and max in your registration and password reset rules.

  4. 4

    Run php artisan config:cache in production so the key is read from cached configuration.

  5. 5

    Submit a common password in a local form to confirm the validation message appears.

Install
php artisan make:rule NotBreachedPassword
php artisan make:listener CheckEmailBreaches --event="Illuminate\Auth\Events\Registered"
php artisan make:job EnrichCustomerGender
Validate breached passwords with a Laravel rule
<?php
// app/Rules/NotBreachedPassword.php
namespace App\Rules;

use App\Exceptions\FinowlException;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class NotBreachedPassword implements ValidationRule
{
    public function __construct(private bool $failOpen = true) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        try {
            $response = Http::finowl()->timeout(5)->post('/v1/breaches/password', [
                'password' => (string) $value,
            ]);
        } catch (ConnectionException) {
            Log::warning('Finowl password check timed out');
            $this->unavailable($fail);
            return;
        }

        if ($response->successful()) {
            if ($response->json('breached')) {
                $fail('This :attribute has appeared in a data breach. Please choose a different one.');
            }
            return;
        }

        // Log the status and code only, never the submitted value.
        $error = FinowlException::fromResponse($response);
        match ($error->status) {
            400 => null, // empty or over 4096 characters: let max/min rules explain
            401 => Log::error('Finowl rejected FINOWL_API_KEY'),
            429 => Log::warning('Finowl limit hit', ['code' => $error->errorCode, 'retry_after' => $error->retryAfter]),
            default => Log::warning('Finowl password check failed', ['status' => $error->status]),
        };

        if ($error->status !== 400) {
            $this->unavailable($fail);
        }
    }

    private function unavailable(Closure $fail): void
    {
        if (! $this->failOpen) {
            $fail('We could not check this password right now. Please try again shortly.');
        }
    }
}

// In a FormRequest:
// 'password' => ['required', 'string', 'min:12', 'max:4096', 'confirmed', new NotBreachedPassword()],

IN PRODUCTION

Production tips

  • Add bail to the field's rules if you want to skip the API call for short passwords. Laravel then stops at the first failing rule, so min:12 failures never reach Finowl or spend a credit.

  • Keep password in the list of inputs Laravel never flashes to the session, as it is by default, so a failed validation redirect never stores the value in session data.

  • Fake the endpoint in tests with Http::fake and a breached false response, so registration feature tests stay fast and never depend on the network.

Each lookup costs 1 credit, with 100 free credits every month. See pricing and error codes.

A FEW GOOD QUESTIONS

Common questions

Validate breached passwords with a Laravel rule

Grab an API key, paste the example and run it. 100 free credits every month.