Password meters: are they measuring what matters?
Password strength meters have become a ubiquitous feature of web signup forms. But there's a fundamental question beneath the UI: does the meter actually measure what an attacker would do? Traditional estimators typically model entropy as if passwords are random vectors of characters — a meaningful benchmark for brute-force attacks, but a poor model of real-world password choices.
Real users pick patterns: dictionary words, keyboard walks like zxcvbn or asdf, sequences like abcdef or 654321, repetitions, l33t substitutions, and predictable casing (typically the first letter uppercase). Numbers and symbols, when present, frequently follow predictable patterns: years, dates, zip codes. A meter that ignores these patterns gives deceptively confident estimates for passwords that are only marginally harder for a sophisticated cracker — while pushing users toward harder-to-remember combinations. The result is that a user's "strong" password may be only slightly stronger than their original guess, yet much harder to type.
An open-source alternative: zxcvbn
zxcvbn is an open-source estimator built during Dropbox's hackweek to close this gap. It's designed to recognize the patterns attackers actually exploit — dictionary words, keyboard patterns, repeats, dates, and sequences — and it doesn't penalize long passphrases. The project is available on GitHub, and a live demo is hosted at tryzxcvbn.
The comparison below illustrates the divergence between zxcvbn and a sampling of other meters (screenshots taken April 3, 2012):
| qwER43@! | Tr0ub4dour&3 | correcthorsebatterystaple | |
|---|---|---|---|
| zxcvbn | ![]() | ![]() | ![]() |
| Dropbox (old) | ![]() | ![]() | ![]() |
| Citibank | ![]() | ![]() | ![]() |
| Bank of America | (not allowed) | (not allowed) | (not allowed) |
![]() | ![]() | ![]() | |
| PayPal | ![]() | ![]() | ![]() |
| eBay | ![]() | ![]() | (not allowed) |
![]() | ![]() | ![]() | |
| Yahoo! | ![]() | ![]() | ![]() |
| Gmail | ![]() | ![]() | ![]() |
Several notable differences emerge. zxcvbn evaluates correcthorsebatterystaple as the strongest of the three example passwords, while other meters either rank it weakest or disallow it outright. Conversely, zxcvbn assesses qwER43@! as weak because it's a short QWERTY spatial pattern, assigning additional entropy for turns and shifted characters. Several meters impose length caps that reject long passphrases entirely; Bank of America, for instance, disallows passwords over 20 characters, and also rejects passwords containing & or !. Context: Burnett's password-frequency analysis suggests 91% of passwords appear in the top 1,000 most common — a powerful argument that pattern recognition matters.
Install and API
zxcvbn has no external dependencies and works on IE7+, Opera, Firefox, Safari, and Chrome. The recommended approach is to load the core script asynchronously:
<script type="text/javascript" src="zxcvbn-async.js">
</script>
The async loader itself is roughly 350 bytes; zxcvbn.js — most of which is dictionary data — comes in at 680K (320K gzipped). In practice this works well, since a user typically doesn't reach the password field instantly.
The library exposes one global function:
zxcvbn(password, user_inputs)
It returns a result object with password-strength scoring properties:
result.entropy # bits
result.crack_time # estimation of actual crack time, in seconds.
result.crack_time_display # same crack time, as a friendlier string:
# "instant", "6 minutes", "centuries", etc.
result.score # 0, 1, 2, 3 or 4 if crack time is less than
# 10**2, 10**4, 10**6, 10**8, Infinity.
# (helpful for implementing a strength bar.)
result.match_sequence # the detected patterns used to calculate entropy.
result.calculation_time # how long it took to calculate an answer,
# in milliseconds. usually only a few ms.
The optional user_inputs parameter accepts an array of strings that zxcvbn adds to its internal dictionary. This is intended for form data like name and email, so passwords derived from personal information are strongly penalized. Site-specific vocabulary works here too — Dropbox's implementation includes dropbox.
zxcvbn is written in CoffeeScript, and both zxcvbn.js and zxcvbn-async.js are compiled with the Closure compiler. The README contains development setup instructions for extending the code.
How the model works
zxcvbn operates in three stages: match, score, and search.
- Match enumerates all (potentially overlapping) detectable patterns. Currently it recognizes multiple dictionaries (English words, names, surnames, and Burnett's 10,000 common passwords), QWERTY/Dvorak/keypad spatial patterns, repeats like
aaa, sequences like123, years from 1900 through 2019, and dates in several formats. Uppercasing and common l33t substitutions are handled for all dictionaries. - Score assigns an entropy value to each matched pattern, independent of the rest of the password, assuming the attacker knows the pattern. For example,
rrrrris scored by iterating over all repeat lengths from 1 to 5 that start with a lowercase letter:
entropy = lg(26*5) # about 7 bits
- Search applies a minimum-entropy principle: given the full set of overlapping matches, it finds the simplest non-overlapping sequence — i.e., the one with the lowest total entropy. This matters; consider the word
damnation, which could be segmented asdam+nationor as one word. An attacker trying dictionary words would crack it as one word far sooner.
Minimum entropy search
zxcvbn defines a password's entropy as the sum of its constituent patterns. Any gaps between detected patterns are treated as brute-force sections that contribute equally to the total:
entropy("stockwell4$eR123698745") == surname_entropy("stockwell") +
bruteforce_entropy("4$eR") +
keypad_entropy("123698745")
This assumption intentionally underestimates: by treating the structure of a password (the number and arrangement of patterns) as free information, zxcvbn assumes attackers already know the structure and begin guessing accordingly. This is a deliberate design choice for three reasons:
- Statistical models of structural entropy are hard to formulate reliably; underestimation is safer than overconfidence.
- For complex structures, the accumulative entropy of the pieces alone is often sufficient for an "excellent" rating.
- Most users do not choose complex structures, so discarding structural entropy loses only a few bits in the common case.
The search algorithm below runs in O(n·m) time for a length-n password with m candidate (possibly overlapping) matches:
# matches: the password's full array of candidate matches.
# each match has a start index (match.i) and an end index (match.j) into
# the password, inclusive.
minimum_entropy_match_sequence = (password, matches) ->
# e.g. 26 for lowercase-only
bruteforce_cardinality = calc_bruteforce_cardinality password
up_to_k = [] # minimum entropy up to k.
backpointers = [] # for the optimal sequence of matches up to k,
# holds the final match (match.j == k).
# null means the sequence ends w/ a brute-force char
for k in [0...password.length]
# starting scenario to try to beat:
# adding a brute-force character to the minimum entropy sequence at k-1.
up_to_k[k] = (up_to_k[k-1] or 0) + lg bruteforce_cardinality
backpointers[k] = null
for match in matches when match.j == k
[i, j] = [match.i, match.j]
# see if minimum entropy up to i-1 + entropy of this match is less
# than the current minimum at j.
candidate_entropy = (up_to_k[i-1] or 0) + calc_entropy(match)
if candidate_entropy < up_to_k[j]
up_to_k[j] = candidate_entropy
backpointers[j] = match
# walk backwards and decode the best sequence
match_sequence = []
k = password.length - 1
while k >= 0
match = backpointers[k]
if match
match_sequence.push match
k = match.i - 1
else
k -= 1
match_sequence.reverse()
# fill in the blanks between pattern matches with bruteforce "matches"
# that way the match sequence fully covers the password:
# match1.j == match2.i - 1 for every adjacent match1, match2.
make_bruteforce_match = (i, j) ->
pattern: 'bruteforce'
i: i
j: j
token: password[i..j]
entropy: lg Math.pow(bruteforce_cardinality, j - i + 1)
cardinality: bruteforce_cardinality
k = 0
match_sequence_copy = []
for match in match_sequence # fill gaps in the middle
[i, j] = [match.i, match.j]
if i - k > 0
match_sequence_copy.push make_bruteforce_match(k, i - 1)
k = j + 1
match_sequence_copy.push match
if k < password.length # fill gap at the end
match_sequence_copy.push make_bruteforce_match(k, password.length - 1)
match_sequence = match_sequence_copy
# or 0 corner case is for an empty password ''
min_entropy = up_to_k[password.length - 1] or 0
crack_time = entropy_to_crack_time min_entropy
# final result object
password: password
entropy: round_to_x_digits min_entropy, 3
match_sequence: match_sequence
crack_time: round_to_x_digits crack_time, 3
crack_time_display: display_time crack_time
score: crack_time_to_score crack_time
backpointers[j] identifies the pattern that ends at position j in the optimal sequence (or null if none). As is typical for dynamic programming, the optimal path is reconstructed backward from the end.
Performance is a real constraint since this runs as the user types. Earlier tests with a naive O(2m) subset-summing approach slowed down quickly; the current implementation completes in a few milliseconds for most passwords. On Chrome with a 2.4 GHz Intel Xeon, correcthorsebatterystaple averages about 3ms, while the deliberately complex coRrecth0rseba++ery9/23/2007staple$ averages around 12ms.
Threat model and crack-time estimates
Raw entropy bits are abstract. To turn them into actionable guidance, zxcvbn must decide what cracking looks like:
- Passwords are stored as salted hashes (unique salt per user), making rainbow tables infeasible.
- An attacker steals all hashes and salts, then guesses offline at maximum rate.
- The attacker uses several CPUs in parallel.
From those assumptions, the estimated time-to-crack is computed as follows (including a 0.5 term to represent average crack time rather than full-space time):
# for a hash function like bcrypt/scrypt/PBKDF2, 10ms is a safe lower bound
# for one guess. usually a guess would take longer -- this assumes fast
# hardware and a small work factor. adjust for your site accordingly if you
# use another hash function, possibly by several orders of magnitude!
SINGLE_GUESS = .010 # seconds
NUM_ATTACKERS = 100 # number of cores guessing in parallel.
SECONDS_PER_GUESS = SINGLE_GUESS / NUM_ATTACKERS
entropy_to_crack_time = (entropy) ->
.5 * Math.pow(2, entropy) * SECONDS_PER_GUESS
This threat model is conservative. A 100-core attacker dedicating full compute to a single user's password is an unlikely scenario for most applications — typical attackers must handle network latency, throttling, and CAPTCHAs when guessing online. Used as a baseline, though, it provides a defensible and useful estimate that aligns with offline-cracking risk.
Scoring Each Pattern
Once zxcvbn identifies a pattern, it computes an entropy estimate with calc_entropy(), which dispatches to a scoring function specific to the pattern type. Two of those scorers are worth examining in detail.
Spatial patterns
Consider the spatial pattern qwertyhnm. It starts at q, spans nine characters, and makes three turns: right, down-right, then right. An attacker searching for such patterns would need to consider every starting key, every turn count up to the observed one, and every possible turn-point configuration.
s # number of possible starting characters.
# 47 for QWERTY/Dvorak, 15 for pc keypad, 16 for mac keypad.
L # password length. L >= 2
t # number of turns. t <= L - 1
# for example, a length-3 password can have at most 2 turns, like "qaw".
d # average "degree" of each key -- the number of adjacent keys.
# about 4.6 for QWERTY/Dvorak. (g has 6 neighbors, tilda only has 1.)
The full space of possibilities covers all spatial patterns of length L or less with t turns or less:
Here (i – 1) choose (j – 1) counts the ways to arrange turn points in a length-i pattern with j turns, with the -1 on both terms because the first turn always occurs at the first character. Each of the j turns offers d possible directions, giving dj possibilities per configuration, and the s term accounts for the starting key. This is deliberately approximate: many combinations counted in the formula, such as "start at q and move left" for a one-turn pattern of length five, are physically impossible on any real keyboard.
lg = (n) -> Math.log(n) / Math.log(2)
nPk = (n, k) ->
return 0 if k > n
result = 1
result *= m for m in [n-k+1..n]
result
nCk = (n, k) ->
return 1 if k == 0
k_fact = 1
k_fact *= m for m in [1..k]
nPk(n, k) / k_fact
spatial_entropy = (match) ->
if match.graph in ['qwerty', 'dvorak']
s = KEYBOARD_STARTING_POSITIONS
d = KEYBOARD_AVERAGE_DEGREE
else
s = KEYPAD_STARTING_POSITIONS
d = KEYPAD_AVERAGE_DEGREE
possibilities = 0
L = match.token.length
t = match.turns
# estimate num patterns w/ length L or less and t turns or less.
for i in [2..L]
possible_turns = Math.min(t, i - 1)
for j in [1..possible_turns]
possibilities += nCk(i - 1, j - 1) * s * Math.pow(d, j)
entropy = lg possibilities
# add extra entropy for shifted keys. (% instead of 5, A instead of a.)
# math is similar to extra entropy from uppercase letters in dictionary
# matches, see the next snippet below.
if match.shifted_count
S = match.shifted_count
U = match.token.length - match.shifted_count # unshifted count
possibilities = 0
possibilities += nCk(S + U, i) for i in [0..Math.min(S, U)]
entropy += lg possibilities
entropy
Dictionary patterns
Dictionary scoring hinges on one key idea:
dictionary_entropy = (match) ->
entropy = lg match.rank
entropy += extra_uppercasing_entropy match
entropy += extra_l33t_entropy match
entropy
The match carries a frequency rank, where common words like the score low and rare ones like maelstrom score high. This lets zxcvbn adapt its assumed dictionary size: if a password uses only widely known words, a cracker can get away with a much smaller word list. That adaptive sizing is why zxcvbn and the xkcd calculation slightly disagree on correcthorsebatterystaple (44 bits vs. 45.2). The xkcd estimate assumed a fixed 211-word dictionary; zxcvbn scales to the words actually used. It also explains why the library ships entire ranked dictionaries rather than a compact Bloom filter: the rank itself is as important as membership.
Uppercase transformations add entropy based on how standard the capitalization is:
extra_uppercase_entropy = (match) ->
word = match.token
return 0 if word.match ALL_LOWER
# a capitalized word is the most common capitalization scheme,
# so it only doubles the search space (uncapitalized + capitalized):
# 1 extra bit of entropy.
# allcaps and end-capitalized are common enough too,
# underestimate as 1 extra bit to be safe.
for regex in [START_UPPER, END_UPPER, ALL_UPPER]
return 1 if word.match regex
# otherwise calculate the number of ways to capitalize
# U+L uppercase+lowercase letters with U uppercase letters or less.
# or, if there's more uppercase than lower (for e.g. PASSwORD), the number
# of ways to lowercase U+L letters with L lowercase letters or less.
U = (chr for chr in word.split('') when chr.match /[A-Z]/).length
L = (chr for chr in word.split('') when chr.match /[a-z]/).length
possibilities = 0
possibilities += nCk(U + L, i) for i in [0..Math.min(U, L)]
lg possibilities
Common forms — first letter uppercased, or all caps — contribute a single bit. Anything else gets a formula based on the number of uppercase and lowercase characters:
The math for l33t substitutions follows the same shape, swapping counts of substituted and unsubstituted characters for the upper/lower tallies.
Finding the Patterns
The dictionary matcher is a brute-force substring scan against a rank-ordered word map:
dictionary_match = (password, ranked_dict) ->
result = []
len = password.length
password_lower = password.toLowerCase()
for i in [0...len]
for j in [i...len]
if password_lower[i..j] of ranked_dict
word = password_lower[i..j]
rank = ranked_dict[word]
result.push(
pattern: 'dictionary'
i: i
j: j
token: password[i..j]
matched_word: word
rank: rank
)
result
The l33t matcher builds on dictionary_match as a primitive, trying substituted forms of the password. Spatial sequences such as bvcxz are recognized via an adjacency graph that counts turns and shifts as it walks the keyboard. Date and year patterns are picked up with regular expressions. Full implementations live in matching.coffee.
Source Data
The password corpus is Mark Burnett's 10,000-entry list, released in 2011. First and last names come from the frequency-ranked 2000 US Census, with the surname list truncated at the 80th percentile (covers 80% of the population) and first names at the 90th, largely to keep Internet Explorer 7 from choking on long arrays. English word frequencies come from a Wiktionary project that counted roughly 29 million words across US television and film. The reasoning was that scripted dialogue would reflect popular word choices better than other available corpora, though that is an untested hypothesis. The list shows its age: Frasier ranks as the 824th most common word.
Limits and Trade-offs
A perfect estimator would converge with a perfect cracker: ideal entropy would equal the number of guesses a smart attacker needs. That kind of accuracy is not the goal. The goal is sound password advice, and that makes under-estimation tolerable — stronger passwords are annoying, not dangerous.
The harder problem is coverage. zxcvbn misses words that drop their first letter, omit vowels, are misspelled, or appear in n-grams, plus zipcodes, disconnected keyboard runs like qzwxec, and more. Missing an obscure pattern is acceptable; missing a common one overestimates strength, which is the worst failure mode. Three improvements would help most:
- Beyond English, the lexicon skews American in both vocabulary and names because of its census source. Keyboard layouts are similarly limited. Country-specific datasets with selectable downloads would broaden coverage.
- Common phrases like "Harry Potter" currently score as a name plus a surname, not as the single high-frequency phrase they are. Larger corpora such as Google's n-gram data are too heavy for a browser-side library; catching phrases would require server-side evaluation and its attendant infrastructure cost.
- Edit-distance tolerance would catch misspellings and truncated words, but it makes word segmentation far harder, especially when l33t substitution is also in play.
Within those limits, zxcvbn is a workable replacement for the usual password meter — better advice than a simple length check for a problem that is not going away. The code is open for forking on GitHub.




















