jord-jd/password_exposed
Check if a password has been exposed in a data breach using the haveibeenpwned.com API. Provides a simple password_exposed helper and a PasswordExposedChecker class, returning EXPOSED, NOT_EXPOSED, or UNKNOWN, with support for SHA1 hashes and PHP 7.1+.
Install and confirm the helpers load.
composer require jord-jd/password_exposed
php artisan tinker
>>> password_exposed('hunter2'); // 'exposed' | 'not_exposed' | 'unknown'
There is no service provider, no config file, and no facade. The package registers two global helper functions via Composer's files autoload (password_exposed, password_exposed_by_hash, plus the boolean-returning password_is_exposed / password_is_exposed_by_hash), so they are available the moment composer dump-autoload runs. Nothing to publish, nothing to configure.
Where to look first.
README.md — the PasswordStatus switch example is the whole API surface.src/PasswordExposedChecker.php — the constructor reveals your three extension points: a PSR-6 CacheItemPool, a PSR-18 HTTP client, and a Certainty bundle.src/PasswordStatus.php — the three constants you branch on.First use case: block breached passwords at registration. That is the highest-value, lowest-disruption placement. Resets and password changes are equally safe. Login-time enforcement is a separate product decision (it forces existing users into a reset flow) — don't ship it in the same increment.
// app/Rules/NotBreached.php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class NotBreached implements ValidationRule
{
public function __construct(private bool $failClosed = false) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$exposed = password_is_exposed($value); // true | false | null on API error
if ($exposed === true) {
$fail('This password has appeared in a known data breach. Please choose another.');
} elseif ($exposed === null && $this->failClosed && app()->isProduction()) {
$fail('We could not verify your password right now. Please try again.');
}
}
}
'password' => ['required', 'confirmed', Password::min(12), new NotBreached(failClosed: true)],
Wire it into the shared rule set (Password::defaults(), Fortify's PasswordValidationRules trait, or Breeze's RegisterController/NewPasswordController) so registration, reset, and change all inherit it in one place.
Centralise the policy, not the call. The EXPOSED / NOT_EXPOSED / UNKNOWN triad is a product decision, so put it in one class rather than sprinkling switch statements through controllers:
// app/Support/PasswordBreachPolicy.php
public function shouldReject(?bool $exposed): bool
{
return match (true) {
$exposed === true => true, // breached: always block
$exposed === null => (bool) config('auth.hibp.fail_closed', false), // outage
default => false,
};
}
Set fail_closed=false in production by default: HIBP outages should not lock you out of onboarding new users. Make it a config flag so the decision is documented and reversible.
Wrap it behind your own contract. The package exposes a concrete class with no interface, so bind your own so tests and future swaps don't touch call sites:
// AppServiceProvider::register()
$this->app->singleton(PasswordBreachChecker::class, fn ($app) => new PasswordBreachChecker(
new \PasswordExposed\PasswordExposedChecker(
// PSR-6 pool sharing Laravel's cache store
new \Symfony\Component\Cache\Adapter\Psr16Adapter($app['cache']->store('hibp'))
)
));
Check the constructor's parameter order in vendor/ — it has changed across majors, and positional injection will bite you on upgrade.
Lean on prefix-keyed caching. The package caches the raw HIBP range response under the first five SHA-1 characters, not the verdict. That means one API call warms the cache for every password sharing that prefix — on a real user base the hit rate is very high. Give it a persistent store (Redis/database), not array, or every request pays a round trip.
Handle the latency budget deliberately. A cache miss is a synchronous outbound HTTPS call (~100–300 ms, up to Certainty's 5 s timeout on a bad day). That is acceptable on registration/reset. It is not acceptable inside a loop, a queued bulk import, or a login hot path.
Check on next login, not by sweeping the table. You cannot retro-scan existing users: passwordExposedByHash() needs the SHA-1 of the plaintext, and your users' passwords are bcrypt/argon2. The only moment you have plaintext is the request itself. So if you need retroactive enforcement, do it opportunistically at successful login and flag the account for a forced reset — and only if the UX team has signed off.
Pin and migrate cleanly. This package replaces divineomega/password_exposed; if you're migrating, remove the old requirement rather than requiring both. On Laravel 11+ you need v5.1.0 or later so the PSR-6 v4/PSR Cache 3 adapters resolve — the older constraints will collide with modern symfony/cache.
Password::uncompromised() (Laravel 8+) performs the same k-anonymity range request internally via Http. Adopt this package only if you want the tri-state status, the nullable boolean, injectable caching/HTTP, or you're supporting a codebase pinned below that. Don't accidentally enforce both — you'll double the timeout exposure.UNKNOWN is the whole story. Any network error, timeout, or malformed response yields UNKNOWN (or null from the boolean helpers), never an exception. That is convenient and dangerous: a silent API failure looks identical to "not checked." Log at warning when status is UNKNOWN so you notice a persistent outage rather than assuming every password was screened.PasswordStatus is a constants class (PHP 7.1 support predates enums). === comparisons and match arms on the constants work fine; ->name, ::from(), and instanceof do not.function_exists guard, so declaring your own password_exposed() helper is a fatal redeclare. Namespace your helpers.rename() warnings. On Docker/Kubernetes, warm the bundle during build or point it at a writable path — otherwise your first registration after a deploy pays the download and may emit warnings under concurrency.App\Http\Kernel / ValidatesRequests::$dontFlash covers your field names, and keep password out of any request-dump middleware. The SHA-1 digest is also worth treating as sensitive — the five-character cache key is deliberately short for that reason.RateLimiter and keep it behind auth.password has SHA-1 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8 and is famously exposed; a random 40-char hex string is reliably clean. Better still, bind a fake implementation of your own contract in tests/ so CI never depends on HIBP availability.sodium extension, certificate-bundle verification is slow; the package falls back but you'll feel it. PHP 8.1–8.5 is covered by CI as of v5.0.3.How can I help you explore Laravel packages today?