Skip to content

pwned: check a password against breaches without sending it

The problem

A sign-up form should refuse a password already in a breach list: attackers try those first. But sending the password, or its hash, to another server to ask "is this one known?" hands over the thing you are protecting. An unsalted SHA-1 of a common password is as good as the password.

The method

Ask about a crowd, not a person (k-anonymity, as the Pwned Passwords range API offers it):

  1. SHA-1 the password.
  2. Send only the first 5 hex characters: GET /range/5BAA6.
  3. The answer lists every breached hash that starts that way, about two thousand suffixes with how often each was seen.
  4. Look for your other 35 characters in that list, locally.

About a million prefixes exist, so each is shared by every password whose hash starts that way. The service learns the crowd, never the member.

Three details make it hold up:

  • Padding. Add-Padding: true adds fake entries with count 0, so the answer's size hints at nothing. They must never count as a match.
  • Unknown is not clean. If the service cannot be asked, the answer is null, never false. The caller decides whether the sign-up goes on.
  • The command reads stdin. An argument shows in ps and shell history, so the command refuses one.

Sample

const hex = (b) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, '0')).join('').toUpperCase();
const hash = hex(await crypto.subtle.digest('SHA-1', new TextEncoder().encode(password)));
const res = await fetch('https://api.pwnedpasswords.com/range/' + hash.slice(0, 5), { headers: { 'Add-Padding': 'true' } });
const line = (await res.text()).split('\n').find((l) => l.trim().split(':')[0] === hash.slice(5));
const count = line ? Number(line.trim().split(':')[1]) : 0;   // 0 = not found (padding counts 0 too)

Use it:

printf %s "$PW" | pwned      # exit 0 clean, 1 breached, 2 could not ask

Limits

  • It answers one question: has this exact password been seen in a breach. Pair it with a length rule.
  • The hash is of the exact UTF-8 bytes; differently composed accents are different passwords.
  • A browser page needs connect-src https://api.pwnedpasswords.com in its Content Security Policy, and should say that it asks.
  • Not found means not known, not safe.

Code

methods/pwned/ in the methods repository: a dependency-free module for browsers and Node, a command, a README and a test against a stand-in server that proves only the prefix leaves.