zxcvbn is the most widely used password strength estimator with 1M npm downloads a week. It's also 389KB gzipped and hasn't shipped a commit since 2017. Most sign-up forms are hauling that around just to block password123.
Poor password UX is a real conversion problem. A strength meter that adds 389KB to your bundle delays page load — on mobile, measurably so. Users who hit a slow registration page don't wait. They leave. The irony is that most of that weight goes toward catching passwords nobody is actually using to register on your site.
So I built passcore - 3.0KB gzipped and 98.4% detection rate on real breach data - same as zxcvbn, benchmarked against a deduped list of passwords pulled live from RockYou, Adobe, HIBP, and other major leak lists.
zxcvbn takes ~9.7ms to load — it's parsing 389KB of dictionary into memory on every cold start. passcore loads in ~0.2ms. It evaluates a password in ~2,600 nanoseconds. For a registration form, it's effectively invisible — no jank, no layout shift, no contribution to your Core Web Vitals score. The strength meter shows up before the user finishes typing their first character.
How it works:
passcore runs five detection layers on every password:
- Dictionary - All entries sourced directly from breach data, not a generic word list
- Keyboard patterns -
qwerty,asdf,1234, numpad walks - Repeats -
aaaa,ababab - Sequences -
abcdef,123456 - L33t speak - decodes
p@ssw0rd→password,m0nk3y→monkey, then dictionary lookup
The dictionary is small by design. Every entry was chosen because it appears in real breach data - not because it's a common English word. Password1! is caught not by a 40k word list but by stripping the suffix and checking if the core word is in the breach list. It is.
The scoring model:
passcore returns a score from 0 to 4 - same scale as zxcvbn.
The detection layers run first. A dictionary match, keyboard pattern, repeat, sequence, or l33t substitution scores 0 or 1 immediately - no further calculation. If none of those fire, scoring falls through to length and character variety: uppercase, lowercase, digits, symbols. A password that clears all five layers but is only 6 characters long still scores low.
There's also a length floor, aligned with NIST SP 800-63B: passwords 20+ characters score at least 3, passwords 30+ characters score 4, regardless of character variety. A passphrase like correct-horse-battery-staple is vastly harder to crack than P@ss1 - the scoring reflects that.
The research:
Getting to 98.4% detection required more than a dictionary lookup. A few problems that came up during development:
Word+affix patterns: Password1!, Admin123, Welcome1 - extremely common in breach data, none of them are in any dictionary as-is. The fix was a matchCommonRoot layer: strip leading and trailing non-alpha characters, check if what's left is a breach word. It is, every time, for this class of password.
L33t speak with separators: N0=Acc3ss decodes to no=access. A naive l33t decoder finds no dictionary match and passes it. The fix was to split the decoded string on non-alpha characters and check each segment independently. access is in the breach list. Caught.
Missing critical roots: Running against real breach lists exposed that admin, test, user, login, pass weren't in the dictionary - meaning Admin123, test1234, user2024 all slipped through. Added those five. Caught.
Switching looks like this:
// before
import zxcvbn from 'zxcvbn';
const { score } = zxcvbn(password);
// after
import { passcore } from 'passcorelib';
const { score } = passcore(password);
One caveat: result.feedback.warning becomes result.warning, making it one level flatter.
| zxcvbn | zxcvbn-ts | passcore | |
|---|---|---|---|
| Bundle (gzipped) | 389 KB | 855 KB | 3.0 KB |
| Speed | 77,578 ns/op | 839,991 ns/op | 2,622 ns/op |
| Detection rate | 98.4% | 98.4% | 98.4% |
| Maintained | No | Yes | Yes |
The tradeoff:
The tradeoff is dictionary size: 329 entries vs 40k+. But the passwords responsible for most credential stuffing aren't obscure literary references - they're Password1!, baseball123, keyboard walks, and l33t variants of the top breach list. passcore catches those.
So that's the bet passcore makes: that 329 targeted entries catch more of what actually matters than 40,000 words that cover everything, including passwords no one uses and/or no attacker is trying. The benchmark agrees — 98.4% detection rate across 370 real breach passwords, same as zxcvbn, at 130x less weight. For the 1% that need exhaustive coverage, use zxcvbn.
TL;DR — zxcvbn is 389KB and abandoned. passcore is 3KB, same detection rate, actively maintained. If bundle size matters to you, it's a near drop-in swap.
Top comments (4)
389KB is wild for what's basically a password strength check. appreciate someone shipped the lighter version.
Great project, thank you for sharing!
A couple thoughts I had and a bit of feedback:
Keep up the great work! It's always refreshing to see someone questioning the status quo and coming up with more sensible solutions! :D
Hi Pascal! Firstly thank you for taking the time to give such detailed feedback, I appreciate you.
On the 329 and whether it was chosen to match the benchmark:
No. The dictionary by itself directly matches 250 of the 370 benchmark passwords — not all of them. The remaining 114 that get caught are flagged by the pattern matchers (keyboard walks, repeats, sequences, l33t substitutions, word+affix detection). The 329 is simply how many breach password entries came out of curating the top passwords from HIBP and SecLists after deduping the compiled list. Structural patterns like pure number sequences and keyboard walks were left to the matchers rather than putting them in the dictionary. The 98.4% detection rate matching zxcvbn was observed after building both independently - not a number that was engineered or tuned to hit.
On user-land dictionaries and regional packages:
Yes, this idea is interesting and something I want to add. The API shape could have a second argument: passcore(password, { dictionary: [...] }) - merged at runtime, zero impact on bundle size for anyone who doesn't use it. Region packages as separate npm packages (passcore-de, passcore-fr etc.) is exactly the community model that would make this useful without bloating the core. Worth opening an issue for this one.
On QWERTZ:
The keyboard rows and patterns in the code are QWERTY-only, so the concern is valid. That said, the 60% adjacency threshold provides more incidental coverage than you'd expect - I tested it:
If 60% or more of consecutive character pairs are adjacent on the QWERTY keyboard, it's flagged as a keyboard pattern.
qwertz is caught (80% of consecutive pairs are adjacent even in the QWERTY map), qwertzuiop is caught (78%), yxcvbnm — the QWERTZ bottom row — is caught (83%). The real gap is short sequences in the t-z swap zone; something like tzui only hits 33% and slips through. Explicit QWERTZ support would be a small targeted fix. If you'd want to contribute it, very welcome.
Once again, thank you for your support and feedback, means a lot :)
Interesting approach to shrinking a widely used tool without sacrificing accuracy—good work! From an infrastructure standpoint, I'm curious how this would perform in a GPU-accelerated setup, especially if you're doing real-time validation at scale. I've seen VoltageGPU help with similar workloads by offloading repetitive checks.