
Kunal TanwarI've been using zxcvbn — Dropbox's password strength estimator — for a while. It's a great library...
I've been using zxcvbn — Dropbox's password strength estimator — for a while. It's a great library but the original is written in CoffeeScript, hasn't been maintained in years and has a long list of open issues nobody every fixed.
So I rewrote it in TypeScript from scratch. Here's what I found along the way
The original library has a few fundamental problems:
any everywhere, no autocomplete, no type safety on the match objectsI wanted a version I could actually use in a modern TypeScript project without fighting it.
zxcvbn-ts — a full TypeScript rewrite with:
Match type for exhaustive pattern narrowingbun add zxcvbn-ts
# or
npm install zxcvbn-ts
ReDoS vulnerability (#327)
The original has a catastrophic backtracking bug in its repeat matcher. This regex:
lazy_anchored = /^(.+?)\1+$/
When fed a crafted string like '\x00\x00' + '\x00'.repeat(54773), it hangs the process. On a server this is a denial-of-service attack.
The fix was replacing the anchored regex with a safe string-length comparison that produces identical results without the backtracking. The attack string now processes in ~79ms instead of hanging indefinitely.
Dictionary matching silently disabled (#326 + lazy init bug)
This one was subtle. The original uses a lazy initialization pattern:
init_ranked_dicts = ->
return if ranked_dictionaries
# ... build dictionaries
In our TypeScript rewrite, setUserInputDictionary() was called before omnimatch(), which triggered the guard and caused the init to short-circuit before the frequency lists were loaded. Every password fell back to bruteforce matching.
The fix: replace lazy init with eager module-level initialization.
// Before — lazy, broken
let RANKED_DICTIONARIES: RankedDictionaries = {};
function initRankedDictionaries(): void {
if (Object.keys(RANKED_DICTIONARIES).length > 0) return; // short-circuits!
// ...
}
// After — eager, correct
const RANKED_DICTIONARIES: RankedDictionaries = Object.fromEntries(
Object.entries(frequencyLists).map(([name, lst]) => [name, buildRankedDict(lst)])
);
Outdated year detection (#318)
The "recent year" regex only matched up to 2019:
recent_year: /19\d\d|200\d|201\d/g
Fixed to cover up to 2039:
recent_year: /19\d\d|20[0-3]\d/g
Capitalisation scoring inconsistency (#232)
uppercaseVariations() didn't strip non-letter characters computing the multiplier, causing 12345Qwert to score higher than 12345qwerT — the opposite of what you'd want.
// Before — computes on "12345Qwert", Q looks mid-word
const word = match.token;
// After — strips digits first, Q is now correctly start-position
const lettersOnly = word.replace(/[^a-zA-Z]/g, "");
12345Qwert and 12345qwerT now both yield 1009 guesses instead of 2521 vs 1009.
No feedback when password matches user input (#231)
If you passed zxcvbn("alice@example.com", ["alice@example.com"]), the score dropped to 0 but the feedback was completely empty. Fixed with a specific case in feedback.ts:
if (match.dictionary_name === "user_inputs") {
return {
warning: "This password is on your personal info list — avoid using personal details",
suggestions: [
"Avoid words or phrases connected to yourself",
"Avoid information others might know about you",
],
};
}
Diacritics not stripped before dictionary lookup (#97)
pässwörd should be caught as a weak password. It wasn't, because the dictionary only contains password. Fixed by NFD-normalising and stripping diacritics before lookup:
const passwordNormalized = passwordLower
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
pässwörd now correctly matches password and scores 0.
This is the part I'm most excited about. The library already knows exactly why a password is weak — it has the full pattern analysis. So I added an optional zxcvbnAI() that sends that structured analysis to an LLM and gets back a plain-English explanation:
import { zxcvbnAI, anthropic, openai, gemini } from "zxcvbn-ts/ai";
const result = await zxcvbnAI("password123", {
provider: anthropic({ apiKey: "sk-ant-..." }),
});
console.log(result.feedback.explanation);
// "Your password combines one of the most commonly used passwords with a
// predictable number suffix. Attackers specifically try these combinations
// first. A passphrase of four or more random words would be far more secure."
Supports Anthropic, OpenAI, Gemini, and any custom adapter. The core library stays zero-dependency — you only pay the cost if you opt in.
| Metric | Original | zxcvbn-ts |
|---|---|---|
| Unpacked Size | 7.72MB | 1.1MB |
| Packed Size | — | 480KB |
| TypeScript | ❌ | ✅ |
| ReDoS Safe | ❌ | ✅ |
| AI Feedback | ❌ | ✅ |
| Recent Years | up to 2019 | up to 2039 |
bun add zxcvbn-ts
npm install zxcvbn-ts
Issues, PRs, and feedback welcome.