DEV Community

Cover image for I rewrote zxcvbn in TypeScript — and fixed 16 bugs the original never addressed
Kunal Tanwar
Kunal Tanwar

Posted on

I rewrote zxcvbn in TypeScript — and fixed 16 bugs the original never addressed

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


Why rewrite it?

The original library has a few fundamental problems:

  • No TypeScript Support — you get any everywhere, no autocomplete, no type safety on the match objects
  • 7.72MB Unpacked — nearly all of it hardcoded string data compiled into every build target
  • Open Security Issues — a ReDoS vulnerability reported in 2023 that was never patched
  • Stale Regex — the "recent year" detector stopped working in 2020

I wanted a version I could actually use in a modern TypeScript project without fighting it.


What I built

zxcvbn-ts — a full TypeScript rewrite with:

  • Strict TypeScript, discriminated-union Match type for exhaustive pattern narrowing
  • Dual CJS/ESM output
  • 93,855 dictionary words bundled
  • Optional AI-powered feedback via Claude, ChatGPT, or Gemini
  • 1.1MB unpacked (down from 7.72MB)
bun add zxcvbn-ts
# or
npm install zxcvbn-ts
Enter fullscreen mode Exit fullscreen mode

The bugs I fixed

ReDoS vulnerability (#327)

The original has a catastrophic backtracking bug in its repeat matcher. This regex:

lazy_anchored = /^(.+?)\1+$/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)])
);
Enter fullscreen mode Exit fullscreen mode

Outdated year detection (#318)

The "recent year" regex only matched up to 2019:

recent_year: /19\d\d|200\d|201\d/g
Enter fullscreen mode Exit fullscreen mode

Fixed to cover up to 2039:

recent_year: /19\d\d|20[0-3]\d/g
Enter fullscreen mode Exit fullscreen mode

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, "");
Enter fullscreen mode Exit fullscreen mode

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",
    ],
  };
}
Enter fullscreen mode Exit fullscreen mode

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, "");
Enter fullscreen mode Exit fullscreen mode

pässwörd now correctly matches password and scores 0.


The AI Feedback Feature

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."
Enter fullscreen mode Exit fullscreen mode

Supports Anthropic, OpenAI, Gemini, and any custom adapter. The core library stays zero-dependency — you only pay the cost if you opt in.

Numbers

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

Try it

bun add zxcvbn-ts
npm install zxcvbn-ts
Enter fullscreen mode Exit fullscreen mode

Issues, PRs, and feedback welcome.

Top comments (0)