DEV Community

Cover image for I built a 3KB alternative to replace zxcvbn (389KB) without detection loss
Fayaz F
Fayaz F

Posted on

I built a 3KB alternative to replace zxcvbn (389KB) without detection loss

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:

  1. Dictionary - All entries sourced directly from breach data, not a generic word list
  2. Keyboard patterns - qwerty , asdf , 1234 , numpad walks
  3. Repeats - aaaa , ababab
  4. Sequences - abcdef , 123456
  5. L33t speak - decodes p@ssw0rdpassword , m0nk3ymonkey , 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);
Enter fullscreen mode Exit fullscreen mode

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.

GitHub · npm

Top comments (4)

Collapse
 
itskondrat profile image
Mykola Kondratiuk

389KB is wild for what's basically a password strength check. appreciate someone shipped the lighter version.

Collapse
 
thormeier profile image
Pascal Thormeier

Great project, thank you for sharing!

A couple thoughts I had and a bit of feedback:

  • Removing 99.2% of the data used by the original lib to save space is a pretty clever tactic - but also a tad dangerous. It would be interesting to know where you made the cut, especially since the number 329 sounds arbitrary at first glance. Is this the top 94.8% of passwords and you chose that exact number to match the benchmarks you did on zxcvbn? What's your reasoning behind that exact number?
  • Perhaps giving the option to define additional dictionary entries would be a nice feature to have? On Github, you mentioned "What it won't catch is a user setting their password to an obscure literary reference, a foreign-language word, or a surname that happens to be in a dictionary but not in breach data." - perhaps having an API to add user-land dictionaries could help cover regional differences? I could imagine that some people would be highly interested in contributing region packages for passcore, too!
  • I read through the code a little and noticed that you're catching sequences from keyboard rows. Coming from a non-US country, I usually use a keyboard that has Y and Z swapped, which is actually rather common in central Europe. Are there any plans to cover these as well? QWERTY, to me, seems equally unsafe as QWERTZ, but the code is likely not catching that.

Keep up the great work! It's always refreshing to see someone questioning the status quo and coming up with more sensible solutions! :D

Collapse
 
fz_1357 profile image
Fayaz F • Edited

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 :)

Collapse
 
voltagegpu profile image
VoltageGPU

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.