passwordpwned

passwordpwned checks whether a password has appeared in a known data breach, using the Have I Been Pwned (HIBP) Pwned Passwords API v3 (https://haveibeenpwned.com/API/v3#PwnedPasswords).

Part of nurago, a collection of independent Go packages for backend services.

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

Package passwordpwned checks whether a password has appeared in a known data breach, using the Have I Been Pwned (HIBP) Pwned Passwords API v3 (https://haveibeenpwned.com/API/v3#PwnedPasswords).

It uses the HIBP k-anonymity model: only the first 5 hex characters of the SHA-1 hash are sent over the network. The API returns all hash suffixes that share that 5-character prefix (typically 800 to 1,000 entries due to Add-Padding). The full hash match is resolved on the client side, so the password and its complete hash never leave the process.

Create a client with New and call [Client.IsPwnedPassword]:

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

pwned, err := c.IsPwnedPassword(ctx, password)
if err != nil {
	log.Fatal(err)
}

if pwned {
	return errors.New("password has been compromised in a data breach, please choose another")
}

To enforce a threshold policy instead (e.g. NIST-style “reject only if seen more than N times”), use [Client.PwnedCount]:

count, err := c.PwnedCount(ctx, password)
if err != nil {
	log.Fatal(err)
}

if count > maxAllowedBreaches {
	return errors.New("password is too common, please choose another")
}

Features

Requests set the Add-Padding header, so every response contains 800 to 1,000 entries regardless of the real match count. Responses are requested as brotli and decoded per their declared Content-Encoding (brotli, gzip, or identity); the decoded body is capped (see WithResponseSizeLimit) to guard against decompression-bomb style memory exhaustion. A 200 response that is not structurally valid range data (e.g. a captive-portal HTML page) is rejected with ErrMalformedResponse instead of being read as “not pwned”. A httpretrier-backed retry policy handles transient network errors for read-only requests and honors a server-provided Retry-After header. Client.PwnedCount returns the raw breach count for threshold policies; Client.IsPwnedPassword returns a boolean. Client.HealthCheck performs a bounded range request to verify the endpoint is reachable. WithURL, WithTimeout, WithHTTPClient, WithUserAgent, WithRetryAttempts, WithRetryDelay, WithResponseSizeLimit, and WithPingTimeout configure the client. HTTPClient is an interface, so a mock HTTP client can be injected in tests.

Security Note

The SHA-1 algorithm is used solely because the HIBP API requires it. The password is hashed locally; only the 5-character prefix is sent over TLS. This package does not store, log, or persist the password or its hash.

When To Use

  • Registration and password-change flows should reject known-breached passwords.
  • A breach-count threshold suits your policy better than a flat reject.

Example

srv := rangeServer()
defer srv.Close()

// Real code omits WithURL and uses the default HIBP endpoint.
client, err := passwordpwned.New(passwordpwned.WithURL(srv.URL))
if err != nil {
	fmt.Println(err)

	return
}

pwned, err := client.IsPwnedPassword(context.TODO(), "password")
fmt.Println("password:", pwned, err)

pwned, err = client.IsPwnedPassword(context.TODO(), "correct horse battery staple")
fmt.Println("passphrase:", pwned, err)

// Output:
// password: true <nil>
// passphrase: false <nil>

Full source is in example_passwordpwned_test.go. More runnable examples are on pkg.go.dev.

Dependencies

Importing this package pulls 1 external module:

  • github.com/aperturerobotics/go-brotli-decoder