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):
- SHA-1 the password.
- Send only the first 5 hex characters:
GET /range/5BAA6. - The answer lists every breached hash that starts that way, about two thousand suffixes with how often each was seen.
- 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: trueadds 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, neverfalse. The caller decides whether the sign-up goes on. - The command reads stdin. An argument shows in
psand 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:
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.comin 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.