Skip to content
All lab notes

Lab note · from Canary

Check a password against Have I Been Pwned without sending it

By Mark Santos · · 5 min read

To check a password against Have I Been Pwned without revealing it, hash it with SHA-1 on the device and send only the first five hex characters to https://api.pwnedpasswords.com/range/{prefix}. Then look for the remaining 35 characters in the list that comes back. The service learns which of 1,048,576 possible prefixes you asked about, never the password or its full hash. Canary, a macOS menu bar breach monitor, does this in HIBPClient.checkPassword. It doesn't yet send the Add-Padding: true header, which stops the response size hinting at the prefix. And its "zero the password after hashing" step wipes a copy, not the password.

Symptoms

Leaking a password to a checking service doesn't break anything, so nothing tells you it happened.

  • Sending the full hash is nearly as bad as sending the password. An unsalted SHA-1 of a password anyone has used before works as a lookup key. The SHA-1 of the throwaway P@ssw0rd is 21BD12DC183F740EE76F27B78EB39C8AD972A757, and on 2026-09-28 its suffix came back from HIBP's own range endpoint with a count of 6,421,042.
  • A review of Canary's version found two quieter gaps. Requests go out without padding. And PasswordHasher.sha1Hash zeroes a byte array after hashing, which reads like wiping the password but only wipes a copy of it.

Why it happens

How the range API keeps the password private

HIBP's API documentation accepts "the first 5 characters of either a SHA-1 or an NTLM hash (not case-sensitive)". It responds with "the suffix of every hash beginning with the specified prefix, followed by a count". The stated purpose is "to protect the value of the source password being searched for". The client does the final comparison, so the server can't tell which of the returned hashes, if any, was the one being checked.

The same page says a range "typically returns approximately 800 hash suffixes, although this number will differ depending on the hash prefix being searched for and will increase as more passwords are added." It has increased. On 2026-09-28, 25 randomly chosen prefixes returned between 1,910 and 2,046 suffixes each (median 1,971). Lines end in CRLF.

No key is involved: "The Pwned Passwords API is freely accessible without the need for a subscription and API key", and "There is no rate limit on the Pwned Passwords API." A user agent is required: "A missing user agent will result in an HTTP 403 response."

Response size can hint at the prefix

TLS hides the URL path but not how many bytes come back. HIBP added padding "such that anyone able to intercept encrypted responses to the API cannot reasonably determine which hash prefix was searched for by observing the response size." The header is Add-Padding: true, and "Padded entries always have a password count of 0 and can be discarded once received." The documentation says padding brings responses to "between 800 and 1,000" records. Real ranges are now past that, so in the same 25-prefix test the header added between 52 and 180 zero-count rows per response.

Zeroing a copy

Array(password.utf8) allocates a new buffer and copies the bytes into it. The defer in Canary's hasher zeroes that buffer. The String passed in is untouched, and so are the other places the plaintext lives: the @State string behind the SecureField, and, for password-manager CSV imports, an array of Strings. AddPasswordView does set password = "" after adding. That gives the property a new value; nothing in the code overwrites the old one's bytes.

The fix

Canary's hasher, from CanaryEngine/Crypto/PasswordHasher.swift:

public static func sha1Hash(of password: String) -> String {
    var bytes = Array(password.utf8)          // a new buffer
    defer {
        for i in bytes.indices { bytes[i] = 0 }   // zeroes that buffer only
    }
    let digest = Insecure.SHA1.hash(data: bytes)
    return digest.map { String(format: "%02X", $0) }.joined()
}

The range check, from CanaryEngine/API/HIBPClient.swift, trimmed, with the one line it is missing:

public func checkPassword(sha1Hash: String) async throws -> Int {
    let prefix = String(sha1Hash.prefix(5))
    let suffix = String(sha1Hash.dropFirst(5))

    guard let url = URL(string: "\(passwordURL)/range/\(prefix)") else { throw HIBPError.invalidURL }
    var request = URLRequest(url: url)
    request.setValue("Canary", forHTTPHeaderField: "user-agent")
    request.setValue("true", forHTTPHeaderField: "Add-Padding")   // not in Canary yet

    let (data, response) = try await session.data(for: request)
    try validateStatus(response)

    let responseString = String(data: data, encoding: .utf8) ?? ""
    for line in responseString.split(separator: "\r\n") {
        let parts = line.split(separator: ":")
        if parts.count == 2, parts[0].uppercased() == suffix.uppercased() {
            return Int(parts[1]) ?? 0
        }
    }
    return 0
}

Why it works:

  • Only prefix is in the request. The full hash is used in exactly one place, the comparison against each returned suffix, and that happens after the response arrives.
  • The padding line needs no parser change. Padded rows carry a count of 0, so if a padded row ever matched, the function would return 0, the same as "not found". A scratch copy of Canary's client with the header added returned the same live counts as without it: 6,421,042 for P@ssw0rd and 391 for correct horse battery staple.
  • The zeroing does what it says, narrowly. Compiled with swiftc -O (Swift 6.4, arm64), the specialized sha1Hash still calls _bzero, so the optimizer didn't remove the wipe of the dead buffer. It doesn't reach the caller's String. Treat it as removing one extra copy, not as clearing the password from memory.

How to check you've fixed it

  • Compare against the API by hand, with a throwaway password:
H=$(printf '%s' 'P@ssw0rd' | shasum -a 1 | awk '{print toupper($1)}')
curl -s -A "your-app" "https://api.pwnedpasswords.com/range/${H:0:5}" | tr -d '\r' | grep "^${H:5}:"

Your client should report the same count.

  • Confirm padding is on: send the same request with -H "Add-Padding: true" and count lines ending in :0. Without the header there were none. With it, the 21BD1 range had 135.
  • Run the unit tests. HIBPClientTests registers a mock response for https://api.pwnedpasswords.com/range/\(prefix), and the mock answers any other URL with a 404, so a request that carried more than the prefix would fail the test. swift test in CanaryPackage ran 22 tests in 5 suites, all passing.
  • To see whether a wipe survives optimization, build with swiftc -O -emit-assembly and look for the zeroing (_bzero, or zero stores) in the function.

Caveats

  • In Canary, only passwords get k-anonymity. Email checks send the full address: to HIBP's breachedaccount and pasteaccount endpoints, which need an HIBP API key, and to XposedOrNot's breach-analytics?email=. HIBP does offer a k-anonymity email search, "sending the first 6 characters" of the address's SHA-1, on subscriptions that include it.
  • Canary stores the full unsalted SHA-1 so it can re-check on a schedule. For a weak password that hash is as good as the password, so it depends on the store's protection: an in-memory SQLite database written to disk as an AES-256-GCM blob, with the key in the Keychain.
  • DNS monitoring baselines records on the first scan and diffs later ones (Engine.scanDomain), but record capture isn't reliable yet. "A" is whichever address a TCP connection to port 443 reaches. MX, NS and TXT go through DNSServiceQueryRecord, whose callback frees its context on the first answer. dns_sd.h says kDNSServiceFlagsMoreComing means "at least one more result is queued". In a standalone test, every multi-record answer crashed the process: gmail.com MX, NS and TXT, and example.com NS and TXT. In the one we traced, the fault was at the read of the freed context.
  • Versions: macOS 14 deployment target, swift-tools-version 5.10, Swift 5 language mode. The tests and harnesses above ran with Swift 6.4.

More lab notes