Table of contents
Part of nurago, a collection of independent Go packages for backend services.
import "github.com/tecnickcom/nurago/pkg/passwordpwned"
Checks whether a password appears in a known breach, through the Have I Been Pwned Pwned Passwords API v3.
c, err := passwordpwned.New()
pwned, err := c.IsPwnedPassword(ctx, password)
if pwned {
return errors.New("password has been compromised in a data breach, please choose another")
}
What actually leaves the process
This is the question anyone sensible asks before sending passwords to a third party, and the answer is: five hexadecimal characters.
The client computes the SHA-1 hash of the password locally and sends only the first 5 characters of it. The API responds with every hash suffix sharing that prefix, typically 800 to 1,000 entries because HIBP applies Add-Padding to level the response sizes. The full-hash comparison happens on the client.
So the password never leaves the process, the complete hash never leaves the process, and the server learns only that someone asked about one of roughly a million possible hashes. That is the k-anonymity model, and it is what makes the check acceptable on a registration path.
The padding matters more than it appears. Without it, response size would correlate with prefix popularity, which leaks information about the query across many requests. It also means the response is a fixed few tens of kilobytes regardless of the password.
SHA-1 is the algorithm here because it is what the HIBP corpus is indexed under. It is a lookup key against a fixed dataset, not password storage; passwordhash does the storage with Argon2id.
Two APIs, two policies
IsPwnedPassword returns a boolean: seen in a breach at all, or not.
PwnedCount returns how many times, which supports a threshold policy. NIST SP 800-63B recommends checking passwords against breach corpora, and a count lets you reject only above a threshold rather than on any appearance.
The threshold matters in practice. The corpus contains many hundreds of millions of passwords, and some appear exactly once because a single user somewhere chose them. Rejecting on a count of one rejects strong passwords that happen to have been in a breached database. Rejecting above, say, ten catches the passwords that are genuinely common while leaving strong ones alone.
An unverifiable response has to be an error
The check reaches a third-party API, so it can fail: a timeout, a DNS failure, a 503.
A failure is not evidence that the password is safe. Returning “not pwned” when the check could not be performed silently disables the control at exactly the moment of an outage, and nothing in the logs says the policy stopped applying.
The client returns an error instead, so the call site handles it explicitly. Fail open for a registration form and fail closed for a privileged account change is a defensible policy; failing open without noticing is not.
Bounding the check with a short timeout is worth doing, since a signup flow should not block on a third-party outage.
Where to apply it
This belongs at password set time: registration, password change, password reset. That is when the user can still choose something else.
Checking at login has a different consequence: rejecting a login for a password the user already has locks them out of their own account.
Dependencies
This package reaches no external module: it uses only the Go standard library.
Links
- Reference documentation
- Source code
- In depth: Checking Passwords Against Have I Been Pwned Without Leaking Them
- Password storage: passwordhash
- The security guide: /docs/security/
- All nurago packages