Skip to content
Finowl

PHP · Password breach checks

Check breached passwords in PHP before hashing

PHP gives you password_hash and password_verify for storage, and a breach check slots in right before password_hash during signup or a password change. The Finowl endpoint receives the plaintext over HTTPS, hashes it with SHA-1, searches by a 5-character prefix with padding, and returns breached and count. Finowl processes the password transiently and never logs, stores or returns it.

The function below keeps the password out of every message it can produce. Exceptions include the HTTP status and error code but never the input, and the JSON body is built inside the function so it is not stored on an object that a debug dump might print. For the demo, the password is read from an environment variable rather than a command-line argument, which would be visible in the process list.

Steps

  1. 1

    Enable the cURL extension and set FINOWL_API_KEY for PHP-FPM or the CLI.

  2. 2

    Try the script with CANDIDATE_PASSWORD set in your environment and run php check-password.php.

  3. 3

    Call breachedPasswordCount in your registration handler after your own length rules pass.

  4. 4

    Return a form error when the count is above zero, and only then call password_hash.

Check breached passwords in PHP before hashing
<?php

declare(strict_types=1);

/** Returns how often $password appears in breach data. Never log $password. */
function breachedPasswordCount(#[SensitiveParameter] string $password): int
{
    for ($attempt = 0; $attempt < 3; $attempt++) {
        $retryAfter = 1;
        $ch = curl_init('https://api.finowl.co/v1/breaches/password');
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 5,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . getenv('FINOWL_API_KEY'),
                'Content-Type: application/json',
            ],
            CURLOPT_POSTFIELDS => json_encode(['password' => $password], JSON_THROW_ON_ERROR),
            CURLOPT_HEADERFUNCTION => function ($ch, string $header) use (&$retryAfter): int {
                if (stripos($header, 'Retry-After:') === 0) {
                    $retryAfter = max(1, (int) trim(substr($header, 12)));
                }
                return strlen($header);
            },
        ]);
        $body = curl_exec($ch);
        if ($body === false) {
            throw new RuntimeException('Password check network error: ' . curl_error($ch));
        }
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $data = json_decode($body, true) ?? [];
        $code = $data['error']['code'] ?? 'unknown_error';

        switch (true) {
            case $status === 200:
                return $data['breached'] ? (int) $data['count'] : 0;
            case $status === 400:
                throw new InvalidArgumentException("Password failed validation: {$code}");
            case $status === 401:
                throw new RuntimeException('Unauthorized: check FINOWL_API_KEY');
            case $status === 429 && $code === 'rate_limited':
                sleep($retryAfter);
                break;
            default:
                throw new RuntimeException("Password check failed: {$status} {$code}");
        }
    }
    throw new RuntimeException('Password check still rate limited');
}

$count = breachedPasswordCount((string) getenv('CANDIDATE_PASSWORD'));
echo $count > 0
    ? 'Seen ' . number_format($count) . ' times in breach data. Choose another password.' . PHP_EOL
    : 'Not found in breach data.' . PHP_EOL;

IN PRODUCTION

Production tips

  • The #[SensitiveParameter] attribute, available from PHP 8.2, redacts the password from stack traces. On PHP 8.0 and 8.1 the attribute is ignored, so make sure error pages never show argument values.

  • Do not enable CURLOPT_VERBOSE in production for this request. Verbose output can be written to error logs and may include request details you do not want stored.

  • Choose how to behave on a timeout. Allowing the signup and rechecking the password at next login is often friendlier than blocking a real user because of a network issue.

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

A FEW GOOD QUESTIONS

Common questions

Check breached passwords in PHP before hashing

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