divineomega/laravel-password-exposed-validation-rule
A Laravel validation rule that checks passwords against known data breaches via Have I Been Pwned, helping block exposed or compromised credentials during registration and password changes. Easy to add to form requests and validators.
The package ships a single validation rule that checks a password against the Have I Been Pwned breach corpus using the k-anonymity range API (only the first five characters of the SHA-1 hash leave your server).
composer require <vendor>/<package>
php artisan vendor:publish --tag=<package>-config), do it now — that is where HTTP timeout, cache store/TTL and fail-open behaviour usually live.string:
'password' => ['required', 'string', 'min:12', 'confirmed', new PwnedPassword()],
Where to look first: the README section added in v6.1.0 on composing the rule with Laravel's built-in validation rules, then the rule class itself (it is small — read it before trusting defaults), then the config file for timeout/cache settings.
Form requests are the natural home. Keep rules declarative and give the message a translatable entry:
public function rules(): array
{
return [
'password' => ['required', 'string', 'min:12', 'confirmed', new PwnedPassword()],
];
}
public function messages(): array
{
return ['password' => 'This password has appeared in a known data breach.'];
}
Only check on write, not on read. Attach the rule to registration, password reset, and password-change endpoints. Profile update forms that do not change the credential should not pay for a network round-trip.
Cache aggressively. The range endpoint is fast but it is still I/O on your request path. Enable the package's cache (or wrap the rule in a decorator backed by Cache::remember) with a TTL of a day or more — the underlying corpus changes rarely enough that staleness is irrelevant for a breach check.
Testing. Fake the range endpoint so the suite stays offline and deterministic:
Http::fake([
'api.pwnedpasswords.com/*' => Http::response("0018A45C4D1DEF81644B54AB7F969B88D65:1\r\n...", 200),
]);
Assert allowed and denied cases through the validator rather than instantiating the rule directly — that is what catches ordering mistakes.
Compose rather than reinvent. Laravel's Password::min(12)->uncompromised() covers the same ground natively. Reach for this package when you need its extra levers: configurable cache store, custom HTTP client/timeouts, a breach-count threshold instead of a binary, or consistent behaviour across the Laravel 8 → 13 range your estate runs.
Integration tip for multi-field credentials. If a form posts an array (password.*, or a JSON payload), validate each element with the same rule set; arrays are no longer coerced, so every element needs string in front of the rule.
The v6.1.0 behaviour change bites silently. Previously a non-string value (integer, null, array) could trigger a conversion warning or a wasted lookup. Now the rule simply leaves non-strings to Laravel's string/type rules — which means an integer password like 123456 now passes the breach rule. Always pair it with string (and required/nullable) upstream. This is the single most likely regression when upgrading.
Stringable objects are still checked. Illuminate\Support\Stringable, enums with __toString, and custom value objects continue down the breach path — good for DTOs, but make sure their __toString() returns what the user actually typed (no trimming or normalisation, or you get a false negative).
Order matters. Put string before the rule in the array. Rule order is evaluation order, and a rule that skips non-strings cannot protect you from a type error further down the chain.
Fail-open vs fail-closed is a product decision. If the API times out or returns 5xx, the default almost certainly lets the password through. For a high-assurance flow, catch the client exception and convert it to a validation failure with a "could not verify, try again" message — otherwise an attacker who can block egress to the API neuters the check.
Offline CI will fail loudly. Any environment without outbound HTTPS will hit timeouts. Disable the rule in testing and local via a config/env flag, or blanket-fake the HTTP client in TestCase::setUp().
Do not log hashes. Even though only a five-character prefix is transmitted, do not put raw password hashes in logs, exception messages, or Sentry breadcrumbs. Truncate in any custom exception you throw.
Rate limits and retries. The range endpoint can return 429. Configure a small retry/backoff on the injected HTTP client rather than letting a legitimate registration fail; the package's cache is the cheaper mitigation.
Upgrading to Laravel 13 / PHP 8.5. No action needed for this package — the release only widens constraints. But if you are moving an app to Laravel 13, audit the rest of your validation stack first; this rule will not be the thing that breaks.
Debugging tips. Http::fake() plus a dd() on the rule's response is faster than reading logs. If passwords unexpectedly pass, check (in order): the rule is in the array at all, string is before it, the value is actually a string, and the cache is not returning a stale "clean" result.
Extension points. Prefer decorating the rule (a class implementing ValidationRule that delegates then adds caching/threshold logic) over subclassing — the class internals are the most likely thing to shift between majors. Config-driven timeout and cache store are the supported knobs; use them instead of editing vendor files.
How can I help you explore Laravel packages today?