Skip to content

Password Strength Analyser — PHP source

Estimate password strength with zxcvbn - realistic dictionary and pattern cracking with crack-time estimates and improvement suggestions. Runs entirely in your browser.

This is the PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.

<?php
/**
 * password-strength-analyser — password strength scoring & feedback.
 *
 * Language: PHP (8.1+, standard library only — mbstring used for safe
 *           multibyte handling, which is effectively universal in modern PHP)
 * Source:   CosmoDev polyglot showcase port of the Password Strength Analyser
 *           tool, ported from cli/password-strength-analyser/password-strength-
 *           analyser.go (the live Go twin, which wraps zxcvbn) and
 *           src/lib/password-strength.ts (the canonical TS lib).
 * License:  display source — part of CosmoDev's polyglot tool pages.
 *
 * Design goals:
 *   - Pure + deterministic; never throws on odd input.
 *   - Self-contained: stdlib only (no Composer packages — no zxcvbn port).
 *   - Mirrors the Go twin's public API and result shape: pw_analyse() returns
 *     an associative array with `score` (0–4), `label`, `entropy` (bits), and a
 *     single crack-time display string.
 *
 * Parity note: the live Go and TS twins both delegate scoring to `zxcvbn`, a
 * dictionary + pattern estimator whose ranking data is far too large to vendor
 * in a stdlib display port. This snippet reproduces the SAME 0–4 scale and the
 * SAME public contract with a self-contained heuristic estimator:
 *   (a) a curated common-password blocklist — forces the obvious weak passwords
 *       (password, 123456, qwerty, ...) to score 0, exactly as zxcvbn's
 *       dictionary does;
 *   (b) a character-pool entropy estimate (length × log2(pool));
 *   (c) penalties for low symbol variety, keyboard / numeric sequences, and
 *       user-supplied related tokens (the user_inputs seed zxcvbn accepts).
 * It agrees with zxcvbn on the clear cases — common passwords score 0, long
 * diverse passphrases score 3–4 — and is a reasonable approximation in between.
 * This is a deliberate, documented trade-off to keep the port dependency-free;
 * for exact scores use the live Go/TS twin.
 */

declare(strict_types=1);

/**
 * Strength labels indexed by score. Identical to the Go/TS twins.
 */
const PW_LABELS = ['Very weak', 'Weak', 'Fair', 'Good', 'Strong'];

/**
 * Curated common passwords (lowercased). An exact match forces score 0 — the
 * observable effect of zxcvbn's dictionary for the most leaked passwords. A
 * small, hand-picked set rather than zxcvbn's ~30k-entry list: enough to make
 * the showcase behaviour meaningful without vendoring a data file. Stored as
 * array keys for O(1) isset() lookup.
 */
const PW_COMMON = [
    '1234' => true, '12345' => true, '123123' => true, '123456' => true,
    '12345678' => true, '123456789' => true, '1234567890' => true,
    '000000' => true, '111111' => true, '654321' => true, '666666' => true,
    'abc123' => true, 'admin' => true, 'baseball' => true, 'batman' => true,
    'dragon' => true, 'football' => true, 'iloveyou' => true, 'letmein' => true,
    'login' => true, 'master' => true, 'monkey' => true, 'passw0rd' => true,
    'password' => true, 'password1' => true, 'princess' => true, 'qwerty' => true,
    'root' => true, 'shadow' => true, 'superman' => true, 'sunshine' => true,
    'trustno1' => true, 'welcome' => true,
];

/**
 * Keyboard rows and numeric / alphabetic runs. Any length-4 window of these
 * appearing inside the password signals an easy-to-guess sequence and incurs
 * an entropy penalty.
 */
const PW_SEQUENCES = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm', '1234567890', '0987654321', 'abcdefg', 'gfedcba'];

/** Min length for a sequence window to count as a match. */
const PW_SEQ_MIN_LEN = 4;

/**
 * Entropy thresholds mapping a bit count to the 0–4 score. Shared across the
 * whole polyglot showcase so every port lands on the same bucket.
 */
const PW_THRESHOLDS = [28.0, 36.0, 60.0, 128.0];

/**
 * Tally the distinct character-class buckets present and return the combined
 * guess-pool size (lower, upper, digit, ASCII symbols, other).
 */
function pw_pool_size(string $password): int
{
    $lower = $upper = $digit = $sym = $other = false;
    // preg_split(//u) splits into a UTF-8 code-point array; mb_ord gives the
    // integer code point for the ASCII range checks below.
    $chars = preg_split('//u', $password, -1, PREG_SPLIT_NO_EMPTY) ?: [];
    foreach ($chars as $char) {
        $cp = mb_ord($char, 'UTF-8');
        if ($cp <= 0x7F) { // ASCII range
            if ($cp >= 0x61 && $cp <= 0x7A) {           // a-z
                $lower = true;
            } elseif ($cp >= 0x41 && $cp <= 0x5A) {     // A-Z
                $upper = true;
            } elseif ($cp >= 0x30 && $cp <= 0x39) {     // 0-9
                $digit = true;
            } else {
                $sym = true;                            // ASCII punctuation/space
            }
        } else {
            $other = true;                              // any non-ASCII code point
        }
    }
    $pool = 0;
    if ($lower) { $pool += 26; }
    if ($upper) { $pool += 26; }
    if ($digit) { $pool += 10; }
    if ($sym) { $pool += 33; }   // printable ASCII non-alnum: 32 punctuation + space
    if ($other) { $pool += 128; } // broad bucket for the rest of Unicode
    return $pool;
}

/**
 * Report whether $lower contains any length-$min_len window drawn from $seq —
 * i.e. a keyboard/numeric run of consecutive symbols.
 */
function pw_contains_sequence(string $lower, string $seq, int $min_len = PW_SEQ_MIN_LEN): bool
{
    $len = mb_strlen($seq, 'UTF-8');
    if ($len < $min_len) {
        return false;
    }
    for ($i = 0; $i <= $len - $min_len; $i++) {
        $window = mb_substr($seq, $i, $min_len, 'UTF-8');
        if (mb_strpos($lower, $window, 0, 'UTF-8') !== false) {
            return true;
        }
    }
    return false;
}

/**
 * Human-readable crack-time for an offline fast attack (1e10 guesses/sec),
 * mirroring zxcvbn's `offline_fast_hashing_1e10_per_second` display. Works in
 * log-space so large entropies (2^200 ≈ 1e60 guesses) stay finite.
 */
function pw_crack_time_display(float $entropy): string
{
    if ($entropy <= 0.0) {
        return 'instant';
    }
    $log10_seconds = $entropy * log10(2.0) - 10.0; // log10(guesses) − log10(1e10)
    if ($log10_seconds < 0.0) {
        return 'instant';
    }
    $seconds = pow(10.0, $log10_seconds);
    if ($seconds < 1.0) {
        return 'instant';
    }
    $minute = 60.0;
    $hour = 3_600.0;
    $day = 86_400.0;
    $month = 2_592_000.0; // 30 days
    $year = 31_536_000.0; // 365 days
    if ($seconds < $minute) {
        return round($seconds) . ' seconds';
    }
    if ($seconds < $hour) {
        return round($seconds / $minute) . ' minutes';
    }
    if ($seconds < $day) {
        return round($seconds / $hour) . ' hours';
    }
    if ($seconds < $month) {
        return round($seconds / $day) . ' days';
    }
    if ($seconds < $year) {
        return round($seconds / $month) . ' months';
    }
    $years = $seconds / $year;
    if ($years < 1_000.0) {
        return round($years) . ' years';
    }
    if ($years < 1_000_000.0) {
        return round($years / 1_000.0) . ' thousand years';
    }
    if ($years < 1_000_000_000.0) {
        return round($years / 1_000_000.0) . ' million years';
    }
    return 'centuries';
}

/**
 * Score a password's strength. $user_inputs seeds the estimator with related
 * tokens (username, site name, ...) — a substring match weakens the score,
 * mirroring the userInputs parameter of the Go/TS twins.
 *
 * @param list<string> $user_inputs
 * @return array{score:int, label:string, entropy:float, crack_time_display:string}
 */
function pw_analyse(string $password, array $user_inputs = []): array
{
    // Empty → trivially weak.
    if ($password === '') {
        return ['score' => 0, 'label' => PW_LABELS[0], 'entropy' => 0.0, 'crack_time_display' => 'instant'];
    }

    $length = mb_strlen($password, 'UTF-8'); // code-point count
    $pool = pw_pool_size($password);
    $log_pool = $pool > 1 ? log($pool, 2.0) : 0.0;

    // Base entropy: length × log2(pool), capped by symbol variety so heavy
    // repetition ("aaaaaaaa") collapses instead of accruing length credit.
    $raw = $length * $log_pool;
    // mb_str_split yields one entry per code point; array_unique dedupes.
    $chars = mb_str_split($password, 1, 'UTF-8') ?: [];
    $unique = count(array_unique($chars));
    if ($unique < $length && $pool > 1) {
        // Repeats earn only a quarter credit: U full picks + 0.25× the repeats.
        $variety = ($unique + ($length - $unique) * 0.25) * $log_pool;
    } else {
        $variety = $raw;
    }
    $entropy = $pool > 1 ? min($raw, $variety) : 0.0;

    // Lowercased view for dictionary / sequence / user-input matching.
    $lower = mb_strtolower($password, 'UTF-8');

    // (a) common-password blocklist forces score 0 and zero entropy, the way
    //     zxcvbn's dictionary collapses a known leak.
    $common = isset(PW_COMMON[$lower]);
    if ($common) {
        $entropy = 0.0;
    } else {
        // (b) keyboard / numeric sequence penalty.
        foreach (PW_SEQUENCES as $seq) {
            if (pw_contains_sequence($lower, $seq)) {
                $entropy -= 12.0;
            }
        }
        // (c) user-input relatedness penalty.
        foreach ($user_inputs as $ui) {
            $l = mb_strtolower((string) $ui, 'UTF-8');
            if (mb_strlen($l, 'UTF-8') >= 3 && mb_strpos($lower, $l, 0, 'UTF-8') !== false) {
                $entropy -= 10.0;
            }
        }
        if ($entropy < 0.0) {
            $entropy = 0.0;
        }
    }

    // Map entropy (and a minimum-length floor) to the 0–4 bucket.
    if ($length < 4 || $entropy < PW_THRESHOLDS[0]) {
        $score = 0;
    } elseif ($entropy < PW_THRESHOLDS[1]) {
        $score = 1;
    } elseif ($entropy < PW_THRESHOLDS[2]) {
        $score = 2;
    } elseif ($entropy < PW_THRESHOLDS[3]) {
        $score = 3;
    } else {
        $score = 4;
    }

    return [
        'score' => $score,
        'label' => PW_LABELS[$score],
        'entropy' => $entropy,
        'crack_time_display' => pw_crack_time_display($entropy),
    ];
}

// ---------- showcase tests (run with: php php.php) ----------
if (PHP_SAPI === 'cli' && isset($argv) && realpath($argv[0]) === __FILE__) {
    $r = pw_analyse('password');
    assert($r['score'] === 0);
    assert($r['label'] === 'Very weak');

    $strong = pw_analyse('correct horse battery staple');
    assert($strong['score'] >= 3);

    assert(pw_analyse('123456')['score'] <= $strong['score']);

    assert(pw_analyse('aB3$xK9p')['entropy'] > pw_analyse('aaaaaaaa')['entropy']);

    foreach (['password', '12345678', 'monkey', 'Tr0ub4dour&3', 'correct horse battery staple', 'u2#9Xq!Lp$7wZ'] as $pw) {
        $row = pw_analyse($pw);
        assert($row['score'] >= 0 && $row['score'] <= 4);
        assert($row['label'] !== '');
        assert($row['crack_time_display'] !== '');
    }

    assert(pw_analyse('cosmolabs2024', ['cosmolabs'])['score'] <= pw_analyse('cosmolabs2024')['score']);

    echo "all showcase tests passed\n";
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →