Skip to content

Password Strength Analyser — JavaScript 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 JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

/**
 * password-strength-analyser - password strength scoring & feedback.
 *
 * Language: JavaScript (ES2020+, runs unmodified in Node 16+ and modern browsers)
 * 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 npm dependencies - no `zxcvbn`).
 *   - Mirrors the Go twin's public API and result shape: `analyse` returns an
 *     object 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 `userInputs` 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.
 */

'use strict';

/**
 * Strength breakdown shape.
 * @typedef {Object} Analysis
 * @property {0|1|2|3|4} score             0 (worst) … 4 (best), identical scale to zxcvbn.
 * @property {string} label                 Human-readable strength label.
 * @property {number} entropy               Estimated guess entropy, in bits.
 * @property {string} crackTimeDisplay      Single human-readable crack-time string (offline-fast model).
 */

/**
 * Strength labels indexed by score. Identical to the Go/TS twins.
 */
const 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.
 */
const COMMON = new Set([
  '1234', '12345', '123123', '123456', '12345678', '123456789', '1234567890',
  '000000', '111111', '654321', '666666', 'abc123', 'admin', 'baseball',
  'batman', 'dragon', 'football', 'iloveyou', 'letmein', 'login', 'master',
  'monkey', 'passw0rd', 'password', 'password1', 'princess', 'qwerty', 'root',
  'shadow', 'superman', 'sunshine', 'trustno1', 'welcome',
]);

/**
 * 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 SEQUENCES = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm', '1234567890', '0987654321', 'abcdefg', 'gfedcba'];

/** Min length for a sequence window to count as a match. */
const 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 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). Iterates UTF-16
 * code units via spread; for the BMP-only inputs this tool targets, code units
 * and code points coincide.
 *
 * @param {string} password
 * @returns {number}
 */
function poolSize(password) {
  let lower = false;
  let upper = false;
  let digit = false;
  let sym = false;
  let other = false;
  for (const c of password) {
    if (c >= 'a' && c <= 'z') {
      lower = true;
    } else if (c >= 'A' && c <= 'Z') {
      upper = true;
    } else if (c >= '0' && c <= '9') {
      digit = true;
    } else if (c.charCodeAt(0) <= 0x7f) {
      sym = true; // ASCII punctuation or whitespace (not alphanumeric)
    } else {
      other = true; // any non-ASCII code point
    }
  }
  let 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-`minLen` window drawn from `seq` -
 * i.e. a keyboard/numeric run of consecutive symbols.
 *
 * @param {string} lower
 * @param {string} seq
 * @param {number} [minLen=SEQ_MIN_LEN]
 * @returns {boolean}
 */
function containsSequence(lower, seq, minLen = SEQ_MIN_LEN) {
  if (seq.length < minLen) return false;
  for (let i = 0; i <= seq.length - minLen; i++) {
    if (lower.includes(seq.slice(i, i + minLen))) 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.
 *
 * @param {number} entropy
 * @returns {string}
 */
function crackTimeDisplay(entropy) {
  if (entropy <= 0.0) return 'instant';
  const log10Seconds = entropy * Math.LOG10E * Math.LN2 - 10.0; // entropy·log10(2) − 10
  if (log10Seconds < 0.0) return 'instant';
  const seconds = 10 ** log10Seconds;
  if (seconds < 1.0) return 'instant';
  const minute = 60.0;
  const hour = 3600.0;
  const day = 86400.0;
  const month = 2592000.0; // 30 days
  const year = 31536000.0; // 365 days
  if (seconds < minute) return `${Math.round(seconds)} seconds`;
  if (seconds < hour) return `${Math.round(seconds / minute)} minutes`;
  if (seconds < day) return `${Math.round(seconds / hour)} hours`;
  if (seconds < month) return `${Math.round(seconds / day)} days`;
  if (seconds < year) return `${Math.round(seconds / month)} months`;
  const years = seconds / year;
  if (years < 1000.0) return `${Math.round(years)} years`;
  if (years < 1e6) return `${Math.round(years / 1e3)} thousand years`;
  if (years < 1e9) return `${Math.round(years / 1e6)} million years`;
  return 'centuries';
}

/**
 * Score a password's strength. `userInputs` 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 {string} password
 * @param {string[]} [userInputs=[]]
 * @returns {Analysis}
 */
function analyse(password, userInputs = []) {
  // Empty → trivially weak.
  if (!password) {
    return { score: 0, label: LABELS[0], entropy: 0.0, crackTimeDisplay: 'instant' };
  }

  const length = [...password].length; // code-point count via the iterator
  const pool = poolSize(password);
  const logPool = pool > 1 ? Math.log2(pool) : 0.0;

  // Base entropy: length × log2(pool), capped by symbol variety so heavy
  // repetition ("aaaaaaaa") collapses instead of accruing length credit.
  const raw = length * logPool;
  const unique = new Set(password).size;
  const variety = unique < length && pool > 1
    // Repeats earn only a quarter credit: U full picks + 0.25× the repeats.
    ? (unique + (length - unique) * 0.25) * logPool
    : raw;
  let entropy = pool > 1 ? Math.min(raw, variety) : 0.0;

  // Lowercased view for dictionary / sequence / user-input matching.
  const lower = password.toLowerCase();

  // (a) common-password blocklist forces score 0 and zero entropy, the way
  //     zxcvbn's dictionary collapses a known leak.
  const common = COMMON.has(lower);
  if (common) {
    entropy = 0.0;
  } else {
    // (b) keyboard / numeric sequence penalty.
    for (const seq of SEQUENCES) {
      if (containsSequence(lower, seq)) entropy -= 12.0;
    }
    // (c) user-input relatedness penalty.
    for (const ui of userInputs) {
      const l = String(ui).toLowerCase();
      if (l.length >= 3 && lower.includes(l)) entropy -= 10.0;
    }
    if (entropy < 0.0) entropy = 0.0;
  }

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

  return {
    score,
    label: LABELS[score],
    entropy,
    crackTimeDisplay: crackTimeDisplay(entropy),
  };
}

// CommonJS export so the file is consumable from Node without a build step,
// while staying dependency-free and framework-agnostic.
module.exports = { analyse, poolSize, containsSequence, crackTimeDisplay, LABELS, COMMON, SEQUENCES };

// ---------- showcase tests (run with: node js.js) ----------
if (require.main === module) {
  const assert = require('assert');
  let r = analyse('password');
  assert.strictEqual(r.score, 0);
  assert.strictEqual(r.label, 'Very weak');

  const strong = analyse('correct horse battery staple');
  assert.ok(strong.score >= 3, `expected >= 3, got ${strong.score}`);

  assert.ok(analyse('123456').score <= strong.score);

  assert.ok(analyse('aB3$xK9p').entropy > analyse('aaaaaaaa').entropy);

  for (const pw of ['password', '12345678', 'monkey', 'Tr0ub4dour&3', 'correct horse battery staple', 'u2#9Xq!Lp$7wZ']) {
    const row = analyse(pw);
    assert.ok(row.score >= 0 && row.score <= 4, `${pw} scored ${row.score}`);
    assert.ok(row.label.length > 0);
    assert.ok(row.crackTimeDisplay.length > 0);
  }

  assert.ok(analyse('cosmolabs2024', ['cosmolabs']).score <= analyse('cosmolabs2024').score);

  console.log('all showcase tests passed');
}

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 →