Checking Passwords Against Have I Been Pwned Without Leaking Them

A wire-level walk through the nurago passwordpwned package: what actually leaves the machine during a Have I Been Pwned check, what the padding hides, and why an unverifiable response must be an error.


Rejecting passwords that have appeared in known data breaches is a high-value check for a registration flow, and the best-known source is Troy Hunt’s Have I Been Pwned (HIBP) Pwned Passwords database. The obvious objection: sending a user’s password, or even its full hash, to a third-party service to ask “have you seen this?” would itself be a security incident.

The passwordpwned package in nurago implements the check in a way that answers this objection. The clearest way to see how is to walk the exchange the way a network observer would: what leaves the machine, what comes back, and what happens when the answer cannot be trusted.


The API

import "github.com/tecnickcom/nurago/pkg/passwordpwned"

c, err := passwordpwned.New()
if err != nil {
    log.Fatal(err)
}

pwned, err := c.IsPwnedPassword(ctx, password)
if err != nil {
    return err // "could not verify", not "safe"; see below
}
if pwned {
    return errors.New("password has been compromised in a data breach")
}

For a National Institute of Standards and Technology (NIST) style threshold policy (“reject only if seen more than N times”), PwnedCount returns the raw breach count instead of a boolean.


What leaves the machine

Strip away the transport and the entire outbound exchange is one GET request, with no body, no cookies, and no query string:

GET https://api.pwnedpasswords.com/range/5BAA6
User-Agent: nurago.passwordpwned/1
Accept-Encoding: br
Add-Padding: true

That 5BAA6 is the only thing derived from the password, and it is the first 5 hexadecimal characters of its Secure Hash Algorithm 1 (SHA-1) digest, computed locally:

sum := sha1.Sum([]byte(password)) //nolint:gosec // SHA-1 is required by the HIBP API.

hash := strings.ToUpper(hex.EncodeToString(sum[:]))

data, err := c.fetchRange(ctx, hash[:prefixLen])

(The example prefix is real: SHA-1 of the string password is 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8.)

Why is a 5-character prefix enough? A 5-character hex prefix partitions the entire SHA-1 output space into 16^5, roughly 1.05 million, buckets, and every one of those ranges exists in the HIBP corpus. So the most privileged observer possible, the HIBP server itself, learns only that you hold some secret whose hash falls in one bucket among a million, a bucket it answers with a long list of suffixes (the 5BAA6 range alone currently holds nearly 2,000 real entries). The remaining 35 characters of the hash, the part that identifies the password, do not leave the process. SHA-1 appears here only because the HIBP API requires it; no security property rests on it, since only a fragment of a locally computed digest crosses the wire, over Transport Layer Security (TLS).


What comes back

The response body is plain text, one line per known hash in the range, in the shape <35-hex-suffix>:<count>. Here is the real data for 5BAA6 at the time of writing:

003CD215739D7C1B2218670D26F81408237:2
003D68EB55068C33ACE09247EE4C639306B:29
00658BFD1E05761042698D19D32CD9F1A8F:15
...
1E4C9B93F3F0682250B6CF8331B7EE68FD8:52372427
...

The last line shown is the suffix of SHA-1(password), seen over 52 million times in breach data. The client matches the local hash’s 35-character suffix against this list in memory. A hit returns the breach count from that line; no hit, or a hit with a count of zero, means not pwned. The lookup direction is inverted: the server publishes its knowledge of the bucket, and the sensitive comparison happens on your side.

A passive network observer sees none of this content through TLS, but sizes still leak: a popular prefix returns more suffixes than a rare one, so response length could correlate with what you are checking. That is what the third request header addresses. The package sets it on every request:

r.Header.Set("Add-Padding", "true")   // All responses will contain between 800 and 1,000 results regardless of the number of hash suffixes returned by the service.

With padding on, the server tops up sparse ranges with fabricated entries, so response size no longer tracks how many real matches the range holds. The fabricated entries always report a count of zero, so they do not register as a hit; the client’s tests pin exactly this case, a suffix that matches but carries :0, as not pwned.


When the wire lies

Now the part that separates a careful client from a careless one. Suppose the check cannot actually reach HIBP: the service sits behind a captive portal that answers every request with an HTML login page and status 200, or a misconfigured proxy serves an error page as a success. A careless implementation reads that body, finds no matching suffix in it (there is none, it is HTML), and returns “not pwned”. A failure to verify has silently become a verification of safety, which is exactly backwards for a security control.

passwordpwned refuses this trade. Before trusting a 200 response, it checks that the body is structurally valid range data:

func validRangeStart(data []byte) bool {
	if len(data) < suffixLen+2 {
		return false
	}

	for _, b := range data[:suffixLen] {
		if (b < '0' || b > '9') && (b < 'A' || b > 'F') {
			return false
		}
	}

	return data[suffixLen] == ':' && data[suffixLen+1] >= '0' && data[suffixLen+1] <= '9'
}

Note what this does and does not check. It validates only the first line: 35 hex characters, a colon, a digit. That is O(1) whatever the body size, and it targets the realistic failure modes: an HTML page, an empty body, or a truncated fragment fails immediately with ErrMalformedResponse, while a response that opens with a well-formed range line is at least speaking the range protocol. The matched line is then validated again at use: a suffix found at the end of a truncated body, or followed by anything other than :<digits>, is also ErrMalformedResponse rather than a guess.

The same discipline applies to decoding. Setting Accept-Encoding manually switches off Go’s transparent gzip handling, so the client decodes the declared Content-Encoding explicitly: brotli (the requested encoding), gzip (from re-encoding proxies), or identity (from plain mirrors). Anything else is rejected with ErrUnsupportedEncoding instead of being read raw. And because the body is decompressed, the decoded stream is capped: the default limit is 8 MiB, generous headroom over the roughly 100 KB a real padded range decodes to today, and the reader takes one byte past the limit so an oversized body is detected and rejected with ErrResponseTooLarge. A decompression bomb costs you a bounded read, not your memory.

Transient failures are retried under a read-only request policy (4 attempts by default), honouring a server-provided Retry-After capped at 60 seconds, so a server-chosen delay stays bounded. Whatever goes wrong, the outcome is designed to be one of two things: an answer that passed the checks above, or a sentinel error (errors.Is-matchable) telling you verification did not happen.


Fail loud

The corollary for your code: treat any error from IsPwnedPassword as “could not verify”, and decide deliberately what to do about it. Failing open (accepting the password) versus failing closed (rejecting or deferring) is a policy choice your application should make explicitly; the library’s job is to make sure the question reaches you as an error instead of being silently answered “safe” on your behalf.

That is the portable principle worth taking away from this package. Any check that guards something, breach lookups, signature verification, permission queries, has two distinct negative outcomes: “verified absent” and “could not verify”. Code that collapses them fails quietly at the worst possible moment, and the collapse is invisible in every test that only exercises the happy path. Validate the shape of what you received, bound what you decode, and keep “I don’t know” clearly distinct from “no”.


A deliberately small client

passwordpwned is a single-purpose client kept tightly scoped: constructor validation up front (URL and User-Agent are checked in New, so a constructed client does not fail on configuration at call time), functional options for every knob (URL, timeouts, retries, user agent, size limit), an HTTPClient interface for painless mocking, and a HealthCheck probe for readiness endpoints. It pairs naturally with passwordhash on the storage side: reject breached passwords at the door, hash the survivors with Argon2id.