Back to writing

How Apps Know Your Password Is Weak

You type password123 into a sign-up form and a red bar appears instantly: "Weak."

You try P@ssw0rd! and it goes yellow: "Fair."

You try xK9#mQ2$vL and it turns green: "Strong."

But how does the app actually know? It didn't check with a server. It happened in milliseconds, right in your browser. What's the logic?

It turns out password strength checking is a genuinely interesting problem - and most apps get it wrong. Let's go through how it actually works.


The Wrong Way: Rules

Most people's mental model of password validation is rule-based:

  • At least 8 characters ✓
  • At least one uppercase letter ✓
  • At least one number ✓
  • At least one special character ✓

This is how the majority of websites still work. It's also a terrible measure of actual strength.

P@ssw0rd1 passes every rule above. It's also one of the most commonly used passwords in the world and would be cracked in seconds by any competent attacker.

correcthorsebatterystaple fails most of those rules - no uppercase, no number, no special character. It's actually far stronger.

Rule-based validation optimises for looking secure rather than being secure. The real question is: how long would it take an attacker to guess this password?


The Right Mental Model: Entropy

Password strength is really about entropy - a measure of unpredictability. The more unpredictable a password is, the more guesses an attacker needs.

Entropy is measured in bits. Each bit doubles the number of possible passwords an attacker has to try.

Entropy = log₂(possible combinations)

For a password drawn from a character set of size C with length L:

Entropy = L × log₂(C)
Character setSize (C)Per character (bits)
Lowercase only264.7 bits
Lower + upper525.7 bits
Lower + upper + digits626.0 bits
All printable ASCII956.6 bits

A purely random 10-character password using all printable ASCII has about 66 bits of entropy - meaning an attacker faces 2⁶⁶ (73 quadrillion) possible combinations.

function calculateEntropy(password) {
  const charsets = [
    { regex: /[a-z]/, size: 26 },
    { regex: /[A-Z]/, size: 26 },
    { regex: /[0-9]/, size: 10 },
    { regex: /[^a-zA-Z0-9]/, size: 33 },
  ];

  const poolSize = charsets.reduce(
    (total, charset) => total + (charset.regex.test(password) ? charset.size : 0),
    0
  );

  return Math.log2(Math.pow(poolSize, password.length));
}

calculateEntropy("password");   // → 37.6 bits (weak)
calculateEntropy("P@ssw0rd1");  // → 59.5 bits (looks stronger...)
calculateEntropy("xK9#mQ2$vL"); // → 65.5 bits (actually strong)

But here's the problem with pure entropy calculation: it assumes the attacker is guessing randomly. Real attackers don't guess randomly.


How Attackers Actually Guess

Modern password cracking tools don't try aaaaaaaa, then aaaaaaab, then aaaaaaac. They use smarter strategies:

Dictionary attacks - try every word in a list of millions of common passwords first. password, 123456, qwerty, iloveyou — these are checked in the first milliseconds.

Pattern mutations - for every dictionary word, apply common transformations: capitalise the first letter (Password), add a number at the end (password1), replace letters with symbols (p@ssw0rd). Attackers know these substitutions by heart because users make them predictably.

Keyboard walks - sequences that follow keyboard layout patterns: qwerty, 1qaz2wsx, zxcvbnm. These look random but are completely predictable.

Markov chains - statistical models trained on leaked password databases that predict which character combinations are likely. Real passwords cluster around human-memorable patterns.

The implication: P@ssw0rd! has decent entropy in theory, but it's in every attacker's mutation dictionary. A strength checker that only measures entropy would call it strong when it's actually very guessable.


zxcvbn: The Right Approach

In 2012, Dropbox engineer Dan Wheeler published zxcvbn - a password strength estimator that thinks like an attacker instead of a rule checker.

Rather than measuring character set entropy, zxcvbn estimates how many guesses it would take a real attacker to crack the password, using the same strategies attackers actually use.

import zxcvbn from 'zxcvbn';

const result = zxcvbn('P@ssw0rd1');

console.log(result.score);           // 2 (0-4 scale)
console.log(result.guesses);         // 105,231
console.log(result.crack_times_display.online_throttling_100_per_hour);
// → "13 hours"
console.log(result.feedback.warning);
// → "This is similar to a commonly used password"
console.log(result.feedback.suggestions);
// → ["Add another word or two. Uncommon words are better."]
const result = zxcvbn('correcthorsebatterystaple');

console.log(result.score);    // 3
console.log(result.guesses);  // 23,481,418,938
console.log(result.crack_times_display.online_throttling_100_per_hour);
// → "centuries"

P@ssw0rd1 scores a 2 despite passing all the common rules. correcthorsebatterystaple scores a 3 despite having no uppercase, numbers, or special characters. The estimator correctly identifies which one is actually harder to crack.


Checking Against Breached Passwords

Entropy and pattern detection tell you if a password is theoretically guessable. But there's another category: passwords that are weak purely because they've appeared in real data breaches and are now in attacker databases.

Tr0ub4dor&3 is a strong password by any entropy or pattern measure. If someone used it as an example in a famous XKCD comic and it spread, it might still be in breach databases.

The right solution is checking against Have I Been Pwned (HIBP) — a database of over 800 million breached passwords.

The clever part: you don't send the password to HIBP's server. You use a k-anonymity model:

  1. Hash the password with SHA-1
  2. Send only the first 5 characters of the hash to the API
  3. The API returns all hashes that start with those 5 characters (hundreds of them)
  4. Check client-side whether your full hash is in the returned list
async function isPasswordBreached(password) {
  const encoder = new TextEncoder();
  const data = encoder.encode(password);
  const hashBuffer = await crypto.subtle.digest('SHA-1', data);

  // Convert to hex string
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase();

  const prefix = hashHex.slice(0, 5);
  const suffix = hashHex.slice(5);

  // Send only first 5 chars to the API
  const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
  const text = await response.text();

  // Check if our suffix appears in the results
  const hashes = text.split('\n').map(line => line.split(':')[0]);
  return hashes.includes(suffix);
}

await isPasswordBreached('password123');     // → true  (found in breaches)
await isPasswordBreached('xK9#mQ2$vL!8qR'); // → false (not found)

The HIBP API never sees your full hash, and you never see anyone else's breached passwords - just the ones that share the same 5-character prefix as yours. The privacy model is elegant.


Putting It Together: A Real Strength Checker

A production password strength checker combines all three layers:

import zxcvbn from 'zxcvbn';

async function checkPasswordStrength(password) {
  // Layer 1: Minimum requirements
  if (password.length < 8) {
    return { score: 0, feedback: 'Password must be at least 8 characters.' };
  }

  // Layer 2: Pattern-based strength estimation
  const estimate = zxcvbn(password);

  // Layer 3: Breach check
  const breached = await isPasswordBreached(password);

  if (breached) {
    return {
      score: 0,
      feedback: 'This password has appeared in a data breach. Please choose a different one.',
    };
  }

  const labels = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'];

  return {
    score: estimate.score,
    label: labels[estimate.score],
    warning: estimate.feedback.warning,
    suggestions: estimate.feedback.suggestions,
    crackTime: estimate.crack_times_display.offline_slow_hashing_1e4_per_second,
  };
}

The three layers catch different failure modes:

  • Minimum length catches genuinely trivial passwords before wasting time on the other checks
  • zxcvbn catches dictionary words, common patterns, and keyboard walks
  • HIBP catches passwords that are strong in theory but compromised in practice

What Good UX Looks Like

The strength bar is only part of the picture. What makes strength checkers actually useful is the feedback.

const result = await checkPasswordStrength('London2024!');

// result.warning → "Dates are often easy to guess"
// result.suggestions → ["Add more words. Avoid years that are associated with you."]

Telling someone their password is weak without saying why is frustrating. Telling them "dates are often easy to guess" is actionable. They can fix it immediately.

The best password UX:

  • Shows strength in real time as the user types
  • Explains the specific weakness, not just the score
  • Suggests a fix, not just a rule
  • Shows estimated crack time - "3 hours" is more visceral than "score: 1"

What This Means as a Developer

If you're building auth today, a few concrete takeaways:

Don't rely on rules alone. Uppercase + number + special character requirements don't measure real strength. They make passwords harder for users to remember without making them harder for attackers to crack.

Use zxcvbn or an equivalent. It's a single npm package, runs entirely client-side, and gives you attacker-perspective strength estimation for free. There's no good reason not to use it.

Check against breach databases. The HIBP API is free, the k-anonymity model preserves privacy, and it catches a class of weak passwords that pattern analysis misses entirely.

Set a minimum score, not just a minimum length. Reject passwords with a zxcvbn score below 2 or 3, depending on how sensitive the account is. A password manager or banking app should require a higher minimum than a newsletter subscription.

Show crack time, not just a label. "This password would take 3 hours to crack with a modern computer" communicates the risk better than a red bar.


Summary

Apps know your password is weak because they think like attackers, not rule checkers:

  • Entropy calculation estimates how many combinations are possible - but assumes random guessing, which attackers don't do
  • Pattern detection (via zxcvbn) checks for dictionary words, common substitutions, keyboard walks, and date patterns - the strategies real crackers use
  • Breach databases (via HIBP) catch passwords that appear in leaked credential dumps, using k-anonymity to check without ever sending your password to a server

The red bar is a surprisingly sophisticated piece of engineering. And it's protecting users from themselves - which, when it comes to passwords, is exactly what it needs to do.


I write about algorithms, security, and full-stack development. Follow me on Twitter/X for more posts like this.