winter/wn-pwnedpasswords-plugin
Adds a Pwned Passwords validation rule to Winter CMS, checking passwords against the Have I Been Pwned breach database using k-anonymity so compromised credentials are rejected during registration and password resets.
Install it like any other Winter plugin — it is a Composer package that lands in plugins/winter/pwnedpasswords:
composer require winter/wn-pwnedpasswords-plugin
php artisan winter:up # runs plugin updates/migrations
php artisan plugin:list # confirm Winter.PwnedPasswords is enabled
If it does not appear as enabled, turn it on in Settings → System → Updates & Plugins, then php artisan cache:clear.
Where to look first, in this order:
plugins/winter/pwnedpasswords/Plugin.php — this is the whole integration surface. It registers the validation rule via Validator::extend() in boot(). The extension name is your API (pwned is the conventional choice; confirm the exact string here before you write it into a form config).README.md — rule syntax, any parameters, and the Winter/PHP version constraints.lang/en/lang.php (or lang/en/validation.php) — the default failure message key, so you know what to override.composer.json — check the winter/wn-system-module / Laravel constraint before adding it to an older 1.1 install.First use case: a password change form you already own. Add the rule to the existing validation string and you are done — no service provider, no config file, no API key:
$validator = Validator::make($input, [
'password' => ['required', 'string', 'min:12', 'confirmed', 'pwned'],
]);
That is the entire value proposition: HIBP breach checking as one token in a rule list, using the range API with k-anonymity (only the first five characters of the SHA-1 hash leave your server).
Backend forms (fields.yaml) — validation is a per-field string, so it composes with everything else:
fields:
password:
label: New password
type: password
validation: required|min:12|pwned
# or: validation: required|min:12|pwned:5 (if the rule accepts a breach-count threshold — verify in Plugin.php)
Frontend components / custom controllers — use the standard Laravel validator; the rule is global once the plugin boots, so it works in Validator::make(), $this->validate(), and FormRequest-style classes alike.
Registration flows. For your own plugin this is a one-liner. For RainLab.User, extend the Account component and append the rule to its rules property before the AJAX handler runs:
\RainLab\User\Components\Account::extend(function ($component) {
$component->rules['password'][] = 'pwned';
});
Check the property name in the installed version of plugins/rainlab/user/components/Account.php — it has changed between major versions. Alternatively, listen for the register event and re-validate the password before the user is persisted, which survives upstream refactors better.
Layer it with strength rules. Breach lists and entropy are orthogonal: Password::min(12)->mixedCase()->numbers() (or zxcvbn) plus pwned gives you "long enough and not already leaked." Prefer this over min:8.
Wrap it for reuse. If you validate passwords in five places, define one constant or a small helper returning the array of rules, so raising min or adding a threshold later is a single edit.
Cache the range lookups if you are busy. The plugin almost certainly calls HIBP per validation. For a high-traffic registration form, wrap the HTTP call in Cache::remember('hibp:'.substr($hash,0,5), now()->addDay(), ...) — the number of distinct five-character prefixes your users generate is tiny compared with the request count.
Use it outside validation where it makes sense — a scheduled command auditing existing weak passwords, or an admin report — by hashing with strtoupper(sha1($password)) and calling the same service/class the rule uses.
Decide what a network failure means. If api.pwnedpasswords.com is unreachable or slow, does the rule fail open (password accepted) or fail closed (user locked out of registration)? Read the rule's passes() method and, if it throws, catch around your own call site. Set an explicit short timeout (2–3s) and consider failing open for registration but closed for password changes.
User-Agent matters. HIBP asks for a descriptive User-Agent; generic or empty ones can get blocked. If the plugin hardcodes one, be aware it will be the same for everyone on your instance — fine, but know where it is if you ever get throttled.
Do not hash before validating. The rule hashes the raw attribute itself. Passing an already-hashed value (common when you "prepare" input in a mutator or prepareForValidation) silently validates garbage and always passes.
Empty values. The rule should be non-implicit and skip null/''; keep nullable or sometimes in front of it so an optional "change password" field does not fire an API call. Also watch '' vs null if you filter input with ConvertEmptyStringsToNull disabled.
Backend AJAX validation is a trap. October/Winter backend fields validate over AJAX on blur by default. A pwned field will therefore make an outbound HTTPS call every time the admin tabs out of the password box — visible lag and pointless load. Either keep the rule only on the save handler, or remove the field's trigger/remote validation and rely on server-side rules at save time.
Testing. Fake the HTTP client rather than hitting the real API in CI:
Http::fake(['api.pwnedpasswords.com/*' => Http::response("0018A45C4D1DEF81644B54AB7F969B88D65:12\r\n", 200)]);
Assert both the breach-hit and the clean-password paths. If the plugin uses its own Guzzle client instead of the Laravel Http facade, you will need Http::preventStrayRequests() plus a container binding — check Plugin.php first.
Message overrides. Set 'password.pwned' => '...' in your own lang/en/validation.php (or use Validator::replacer('pwned', ...)) rather than editing the plugin's language file — the latter is wiped on composer update. Keep the message non-accusatory but actionable: "This password has appeared in a known data breach; please choose a different one."
Privacy note is a feature. Because only a five-character SHA-1 prefix is sent, you can state in your privacy policy that passwords never leave the server in plaintext or in full hash form. Say this explicitly; it is the main reason to use HIBP's range endpoint over a downloadable list.
Small package, small risk — but pin it. Five stars and zero dependents means little external scrutiny: read the ~one source file, pin the version in composer.json, and note that it is MIT and trivially forkable if you need a threshold parameter, caching, or different failure semantics. Copying the twenty lines into your own plugin is a legitimate alternative if you want full control.
Rule name collisions. If another package registers a pwned validator, the last one booted wins. A quick php artisan tinker check (Validator::make(['p' => 'password123'], ['p' => 'pwned'])->fails()) confirms which implementation is actually active.
How can I help you explore Laravel packages today?