CLI tool
HaveIBeenPwned/PwnedPasswordsDownloader avatar
HaveIBeenPwned/PwnedPasswordsDownloader

PwnedPasswordsDownloader: taking the breached password list offline

A tool to download all Pwned Passwords hash ranges and save them offline so they can be used without a dependency on the k-anonymity API

1,333 stars153 forksC#BSD-3-Clause

At a glance

What is it?
haveibeenpwned-downloader is a dotnet global tool that downloads every Pwned Passwords hash range to disk, so password checks work without depending on the k-anonymity API at run time. Each range is verified against a Content-MD5 header, directory downloads keep an ETag index for incremental fetches, and there is an NTLM variant alongside SHA1.
Who is it for?
PwnedPasswordsDownloader suits an organisation that wants to check passwords against the full breach corpus without a network call per lookup, and that can host the resulting data and refresh it on a schedule. It is the wrong choice for a product that must stay current automatically, because the corpus is a snapshot you refresh yourself, and a poor fit if you only need to check a handful of passwords, where the k-anonymity API is simpler.
Can I use it commercially?
Yes. BSD-3-Clause is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 65 days ago.
What is it written in?
Mainly C#, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 20, 2026, and from our analysis. They are not legal advice.

Editorial analysis

What the tool is for: removing the network from a password check

haveibeenpwned-downloader is a dotnet tool that downloads all Pwned Passwords hash ranges and saves them offline so they can be used without a dependency on the k-anonymity API. The k-anonymity API is the interesting context here. It is what lets a service check whether a password appears in the breach corpus without ever seeing the password, because only the first five characters of a hash are sent and the server returns every suffix. That is a good design for a web service, but it means a lookup costs a network round trip, and a network round trip per password check is a dependency you do not want in a login path, in an offline client, or in an environment with an unreliable link. This tool is the other side of that trade. You take the full corpus once, and then the check is a local lookup.

The alternative it names, and why a cURL script is not the same thing

The README points at an alternative to running the tool, which is Zsolt Müller's cURL approach in issue 79 of this repository, one that makes use of a glob pattern and parallelism. It is worth naming because it describes the actual design space. The range files are enumerated by a predictable pattern, so a shell script with curl and a glob can in principle fetch the same set of files with parallel requests. What the tool adds over that is the operational behaviour around the transfer rather than the transfer itself, and the rest of the documentation is largely about that behaviour. For an evaluator, the takeaway is that the fetching is not the hard part and should not be the reason to choose or avoid the tool. The parts worth reading the documentation for are the verification, the incremental index and the retry policy.

Content-MD5 verification, and the rule that a corrupt range fails the run

Every downloaded hash range is verified against the Content-MD5 response header supplied by the Pwned Passwords API before it is accepted. That check is unconditional and requires no configuration. The retry behaviour is also defined: a verification failure is retried according to the --max-retries setting, and if the retries are exhausted the downloader fails rather than reporting the corrupt range as successfully downloaded. That last clause is the part that matters operationally. A tool that logged a warning and moved on would leave you with a corpus that is silently missing entries, and a password that was breached would look safe. Failing the run instead means a truncated corpus is impossible to mistake for a complete one, and it means a flaky network costs you a retry rather than a correctness guarantee.

Incremental directory downloads: the ETag index and what it does not cover

Directory downloads create either sha1.index or ntlm.index in the output directory, and the index is a prefix-sorted, tab-delimited list of range prefixes and ETags, written atomically. On later runs the downloader sends the saved ETag with If-None-Match, so ranges that have not changed are retained locally without downloading their content again. Two edge cases are stated and they are the ones to internalise. Ranges returned without an ETag are not indexed and are downloaded again on each run, so a server that stops sending ETags turns the incremental mode back into a full download without telling you loudly. And indexes are not used with --single, which always creates a complete output file, so the single file mode has no incremental behaviour at all. The --force flag ignores the existing index, downloads every range and rebuilds the index, which is the escape hatch when the index itself is suspect.

SHA1 and NTLM in the same tool, and the two output modes

The downloader fetches SHA1 hashes by default and NTLM hashes when the --ntlm flag is set, and it writes them in one of two shapes. The default for a directory output is individual .txt files, one per range, which is the mode that supports the incremental index. The --single flag instead downloads everything into one file, and as the documentation puts it, indexes are not used with --single, which always creates a complete output file. The practical difference in use is size and lookup strategy. A single file is simpler to ship and to mount, and a directory of ranges is what makes an incremental refresh possible, and it is also what a k-anonymity style lookup would want, since the range prefix selects the file. The parameter table also carries --overwrite, which decides whether output files are replaced, and --parallelism, which defaults to Environment.ProcessorCount.

bash
haveibeenpwned-downloader pwnedpasswords --single
haveibeenpwned-downloader hashes
haveibeenpwned-downloader ntlm_hashes --ntlm
haveibeenpwned-downloader -n pwnedpasswords_ntlm --single

Retries, backoff and the flag that disables them

The --max-retries parameter controls how many times each prefix is retried after a failure, and the default is unlimited. Omit it and the tool keeps trying, or pass 0 to disable retries entirely. The documentation also specifies the shape of the backoff: retry delays increase per prefix up to 10 seconds. Two things follow from that. The default is a tool that will wait rather than fail, which is right for a bulk download running overnight and wrong for a job that needs to finish inside a pipeline window, which is exactly the case where you would set a finite value. And because the delay grows per prefix, a badly behaving range can take a long time to give up. The examples in the README show the intended use, downloading to a single file with 64 threads and overwriting an existing file, and downloading all hashes with at most 5 retries per prefix.

bash
haveibeenpwned-downloader.exe hashes -p 64
haveibeenpwned-downloader.exe pwnedpasswords --single -o -p 64
haveibeenpwned-downloader.exe pwnedpasswords --max-retries 5

Installation, Native AOT and the .NET 10 requirement

Installing the tool needs the .NET 10 SDK or later, and the tool is installed globally with dotnet tool install --global haveibeenpwned-downloader, updated with dotnet tool update --global haveibeenpwned-downloader. The packaging detail matters more than usual. Native AOT, self-contained packages are available for Windows x64, Linux x64 and macOS Apple silicon, and the installed tool does not require a separate .NET runtime in those cases, while other runtime combinations use a framework-dependent fallback package and require the .NET 10 runtime. So a deployment onto a machine you do not control, a container image or a CI runner, gets a single self-contained binary with no runtime to install, as long as you pick one of the three supported targets. The troubleshooting section covers the case where the installer cannot resolve the package, which is fixed by adding the NuGet source explicitly with dotnet nuget add source pointing at the nuget.org index.

bash
dotnet tool install --global haveibeenpwned-downloader
dotnet tool update --global haveibeenpwned-downloader

Licence, layout and what the project is not

The licence is BSD-3-Clause, with the LICENSE file at the repository root. The repository layout is very small, with a src/ directory holding the C# source, a .github/ directory for the workflow, the README and the licence file, and no tests directory, no CI configuration beyond that and no changelog. There are no GitHub releases, since the artefact is a dotnet tool published as a package rather than a tagged binary. The last push to the repository was on 2026-08-19 and the repository is not archived. And the scope is worth stating plainly, because the name can be misread. This is a downloader. It does not check passwords, it does not serve lookups and it does not store anything about users. It produces a local copy of a public corpus so that something else, which you write, can use it.

Editorial conclusion

PwnedPasswordsDownloader suits an organisation that wants to check passwords against the full breach corpus without a network call per lookup, and that can host the resulting data and refresh it on a schedule. It is the wrong choice for a product that must stay current automatically, because the corpus is a snapshot you refresh yourself, and a poor fit if you only need to check a handful of passwords, where the k-anonymity API is simpler. Before adopting it, decide whether you want a single file or a directory of per-range files, since the incremental ETag index only exists for directory downloads and --single always rebuilds the whole output, and note that every range is verified against a Content-MD5 header, so a corrupt download fails the run rather than being silently accepted.

Frequently asked questions

What does haveibeenpwned-downloader do?

It is a dotnet global tool that downloads all Pwned Passwords hash ranges and saves them offline, so they can be used without a dependency on the k-anonymity API. It supports SHA1 hashes by default and NTLM hashes with the --ntlm flag.

How do I install the Pwned Passwords downloader?

Install the .NET 10 SDK or later, then run dotnet tool install --global haveibeenpwned-downloader, and update it with dotnet tool update --global haveibeenpwned-downloader. Native AOT self-contained packages exist for Windows x64, Linux x64 and macOS Apple silicon and need no separate .NET runtime.

How does the downloader verify what it downloads?

Every downloaded hash range is verified against the Content-MD5 response header supplied by the Pwned Passwords API before it is accepted, with no configuration required. A verification failure is retried according to --max-retries, and if retries are exhausted the downloader fails rather than reporting the corrupt range as successfully downloaded.

What does the ETag index do in the Pwned Passwords downloader?

Directory downloads create a sha1.index or ntlm.index file, a prefix-sorted, tab-delimited list of range prefixes and ETags written atomically. On later runs the saved ETag is sent with If-None-Match, so unchanged ranges are kept locally. Ranges returned without an ETag are not indexed and download again each run, and indexes are not used with --single.

What are the main parameters for haveibeenpwned-downloader?

The main ones are -s or --single for one output file, -p or --parallelism for how many hashes to fetch at a time, --max-retries for retry attempts per prefix with unlimited by default and 0 disabling retries, -o or --overwrite for replacing existing output files, -n or --ntlm to fetch NTLM instead of SHA1 hashes, and --force to ignore saved ETags and rebuild the index.

Official sources

  1. HaveIBeenPwned/PwnedPasswordsDownloader on GitHub
  2. Issues
  3. License: BSD-3-Clause
  4. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/haveibeenpwned-pwnedpasswordsdownloader.svg)](https://hysenlabs.com/projects/haveibeenpwned-pwnedpasswordsdownloader)