As of October 8, 2026
A password check can query a breach list without putting the password in an HTTP request. The browser hashes the input, sends five hexadecimal characters, and checks the returned candidates locally. We build small web tools, and we like this approach because the network boundary is explicit enough to inspect.
Have I Been Pwned provides this pattern through its Pwned Passwords range endpoint. The useful question for developers is what crosses that boundary, what stays in memory, and what the answer actually establishes.
Follow the value through the browser
SHA-1 produces a 160-bit digest, represented here as 40 hexadecimal characters. Split that string into a five-character prefix and a 35-character suffix. Request https://api.pwnedpasswords.com/range/{prefix}, substituting the prefix. The response contains candidate suffixes and their occurrence counts.
The browser compares its own suffix with those candidates. It never needs to transmit the remaining hash characters to finish the lookup.
| Value | Where it is used | Sent in the range request? |
|---|---|---|
| Original password | Browser input and hashing | No |
| Complete SHA-1 hash | Browser memory | No |
| First five hexadecimal characters | Range URL | Yes |
| Remaining 35 characters | Local comparison | No |
| Candidate suffixes and counts | Response parsing | Returned by the service |
This is the k-anonymity lookup pattern: one prefix identifies a group of candidate hashes rather than identifying the complete hash in the request. It reduces disclosure. It does not make the request anonymous or hide ordinary network metadata from the service.
A browser function you can run
Use this on an HTTPS page or localhost, where the browser's Web Crypto API is available. Call it from a submit handler with the password field's value. For experiments, use a disposable test string rather than a real credential.
async function breachCount(password) {
const bytes = new TextEncoder().encode(password);
const digest = await crypto.subtle.digest('SHA-1', bytes);
const hash = Array.from(new Uint8Array(digest), byte =>
byte.toString(16).padStart(2, '0')
).join('').toUpperCase();
const prefix = hash.slice(0, 5);
const suffix = hash.slice(5);
const response = await fetch(
`https://api.pwnedpasswords.com/range/${prefix}`,
{ headers: { 'Add-Padding': 'true' } }
);
if (!response.ok) {
throw new Error(`Lookup failed: HTTP ${response.status}`);
}
const lines = (await response.text()).trim().split(/\r?\n/);
for (const line of lines) {
const [candidate, count] = line.trim().split(':');
if (candidate === suffix) return Number(count);
}
return 0;
}
The function resolves to an occurrence count, or zero if its suffix is absent from a successful response. Network failures reject the promise; unsuccessful HTTP responses throw. A calling interface should catch those errors and show “check unavailable.” Turning a failed request into zero would give an unsupported reassurance.
The optional Add-Padding: true request header asks for padding entries, reducing information exposed through response size. Padding entries have zero counts. Our exact suffix comparison therefore still produces zero for a matching padding entry. The endpoint and padding behavior are documented in the official API reference.
Keep the surrounding interface honest
Preserve the entered string exactly. Trimming whitespace or changing capitalization before hashing checks a different password. The uppercase conversion in the snippet applies only to the hexadecimal representation of the digest, so it does not alter the password itself.
We would trigger this check after an explicit action rather than sending a request for every keystroke. Repeated prefixes from partially typed input disclose more than a single completed check. Keep password values out of URLs, console output, analytics events, and error reports, too.
Local hashing describes this lookup mechanism. It cannot guarantee that every other script running on the page behaves safely. A browser implementation still needs a trustworthy page and careful handling of the input. SHA-1 also appears here for compatibility with the lookup dataset; it is not a suitable recommendation for storing account passwords.
Read the result as one piece of evidence
A positive count says the password appears in the dataset. It does not identify which of the reader's accounts was affected, and the count is not a count of breaches involving that particular reader.
Zero means no match was returned for this check. A new but predictable password can still be weak. Password strength estimation is a separate task, and a time-to-crack estimate depends on assumptions about guessing methods and attack conditions. Neither a score nor an estimated duration is a guarantee.
Our free checker follows the local SHA-1 and five-character-prefix flow. It shows the breach count alongside a 0–100 security score and a time-to-crack estimate, and includes a strong-password generator. We describe those outputs separately so the lookup result does not masquerade as a complete security assessment.
FAQ
Does the service receive my password or its full hash?
In the range request shown above, it receives the first five hexadecimal characters only. The password, full hash, and suffix comparison remain in the browser. That statement concerns this code path, not every possible script on a page.
Is a password safe if the count is zero?
No. Absence from the returned dataset does not establish strength or uniqueness. We would still avoid reuse and choose a long, unpredictable password for each account.
Why use SHA-1 when it is unsuitable for password storage?
Here it creates a lookup identifier compatible with the dataset. Secure password storage has different requirements, including resistance to repeated guessing. Reusing this snippet as a password storage design would confuse those two jobs.
Top comments (0)