# What was wrong with password.es, and no longer is

> A site that spends its days saying how things ought to be done owes you an account of when it wasn’t doing them. Here is what we found in our own house, with the numbers.

2026-08-31 · David Carrero · password.es
Original: https://password.es/en/blog/what-was-wrong-with-password-es/

---

This blog has twelve articles explaining how things ought to be done: that length
matters more than symbols, that a manager is not a convenience but the only way
to keep the rule, that a percentage tells you nothing.

Writing that is easy. The uncomfortable part is holding your own site to the same
standard. **We did, and three things were wrong.** They go here in full, with the
numbers that gave them away, because a security site that only publishes its wins
is worth considerably less.

## The checker froze while you were typing

The checker analyses your password with [zxcvbn](https://github.com/dropbox/zxcvbn),
a library that looks for patterns instead of counting capitals. It works very
well and it has a cost: **it runs all at once, on the same thread that draws the
page.** While it runs, the browser can do nothing else.

That cost grows fast with length. Measured with the same bundle the site serves,
using the kind of passwords people actually type — words, years, `@` for `a`:

| length | one run |
|---|---|
| 16 characters | 45 ms |
| 20 characters | 121 ms |
| 32 characters | 273 ms |
| 64 characters | 843 ms |
| 128 characters | 3,621 ms |

So far, normal. The problem was how many times we launched it.

The field had **two** separate listeners attached, `input` and `keyup`. A normal
key press fires both. So every letter you typed ran the full analysis **twice**:
90 ms on a 16-character password, half a second on a 32-character one.

And nothing held it back: no timer, no length cap, no separate thread. Past about
twenty characters, the browser took longer to analyse than you took to press the
next key. Keystrokes piled up in a queue that never drained, and **the page
stopped responding while you typed**. Pasting a long password froze it for
seconds.

The fix is two small changes. We removed the `keyup` listener, because `input`
already covers typing, pasting, deleting and your manager's autofill: half the
work disappears without changing a single thing you see. And we added a 150
millisecond wait: nothing is analysed while you type, and it is analysed the
moment you stop. Nobody types slowly enough to notice.

The result is identical. `Barcelona1992$` still says the same: that «barcelona»
is in the dictionary, that 1992 looks like a year, and that it falls in under a
second.

## The description was cut off mid-sentence in fifteen languages

This one is dumber and more embarrassing.

The line that appears under the title in search results — the meta description —
was **truncated in fifteen of the checker's eighteen languages**. Only Spanish,
Korean and Japanese survived. Someone trimmed it by length at some point, cut
mid-word, and nobody looked afterwards.

In three languages the cut landed in the middle of a special character, so what
we published to Google was this:

| language | what we served | what it should say |
|---|---|---|
| Portuguese | `…datas e padr&o` | `padrões` |
| Turkish | `…arıyor ve ger&cced` | `gerçekten` |
| Vietnamese | `…chúng tôi tra mật kh&#` | `mật khẩu` |

The striking part is where it was: on **the best-performing page on the site**.
The checker converts far better than the generator, and it was doing it with a
broken description. All fifteen are now complete.

While we were there, in Korean, Japanese and Vietnamese we moved the brand from
the front of the title to the end. In search results the domain already shows
separately, so opening with «password.es ·» only spent the space that counts
most.

## A bias of one in a hundred million

The generator picks each character with `crypto.getRandomValues()`, the browser's
good source of randomness. But to turn that huge number into «a position in the
alphabet» we used a remainder, and there is a classic trap there: 2³² is not a
multiple of 83, so **the first positions in the alphabet came up very slightly
more often than the last ones**.

How slightly: with an alphabet of up to 89 characters, on the order of one part
in a hundred million. It changes nothing. It does not make your password
measurably weaker, not remotely.

We fixed it anyway, and it takes three lines: discard the 45 leftover values out
of the 4,294,967,296 possible ones and draw again. On a site about passwords,
«it's negligible» is not a good reason to leave a bias in when removing it costs
that.

## What was missing: letting the browser enforce the promise

We have a whole article explaining that [your password never leaves here](/en/blog/why-password-es-never-sends-your-password/).
It was true, and even so it was only a sentence of ours: nothing technically
stopped an injected script from doing otherwise.

Now every page carries a content security policy. The two parts that matter:

- **No script runs that wasn't in the page when it was built.** Every legitimate
  script is signed with its fingerprint; an injected one doesn't match and the
  browser refuses to run it.
- **`connect-src 'none'`.** The site makes no network request whatsoever from
  JavaScript, and now the browser enforces that. If something ever tried to send
  what you type anywhere, it wouldn't get out.

The promise stopped being a statement and became a rule.

## What is still wrong

**Update, 31 August.** All three items in this section were fixed the same day this article went up: the fifteen languages now describe the real engine, the two scales are one, and the checker's FAQ explains the symbol count. They stay written down, because the record of what was wrong is worth more than a picture of a flawless site.

This is where this article earns its place.

**Fifteen languages still describe the checker as the old engine.** Their text
says it recreates the classic PasswordMeter logic. Not any more: that engine was
replaced because it called `Password1!` very strong and failed a five-word
passphrase, which is exactly backwards. Spanish, Korean and Japanese are updated;
the other fifteen are not.

**There are two different scales in the same house.** The front page decides
whether a password is strong by its bits; the checker decides by how long it
would take to break. Different criteria, and nowhere do we say so.

**The generator and the checker don't count the same symbols**: one uses 27, the
other assumes 33. The difference is small, but it's there.

None of the three is serious. All three are debt, and they would rather be
written down here than waiting for someone else to find them.

## This version's changelog

Since the repository isn't public, the changelog goes here in full. Version
**0.2.0**, 31 August 2026:

**Added.** A per-page content security policy with the fingerprint of every
script. The article on where your saved passwords are, in all eighteen languages.
An editorial queue that decides what gets written, and that orders us to publish
nothing when it runs empty.

**Fixed.** The checker description, cut off mid-sentence in fifteen languages,
three of them with a character split in half. The tab freezing as you typed. The
bias in the draw. And the photo missing from the previous article.

**Changed.** The checker title in Korean, Japanese and Vietnamese. The deployment,
which now fires once the built site exists instead of a minute earlier. And the
Vietnamese, Hindi and Arabic translations, which stop blocking a release even
though they remain the weakest.

---

*The figures in this article are measured, not estimated: the zxcvbn timings with
the same bundle the site serves and passwords containing words and substitutions;
the draw bias, computed over the 4,294,967,296 values of a 32-bit integer. The
checker's engine is [zxcvbn](https://github.com/dropbox/zxcvbn), open source.*
