How to Verify File Downloads: Why a Checksum Alone Is Not Enough

Rahmat Ullah profile photoRahmat Ullah
14 min readSecurity, Developer Tools, Supply Chain

In February 2016, the official Linux Mint website served backdoored ISOs for several hours. The attackers had compromised the WordPress install behind the site and pointed the download links to a server they controlled. The page still looked correct. The HTTPS padlock was still green. And here is the part that should give every download-and-install user pause: the SHA-256 hash shown on that page also matched the malicious file. Because the page was the source of the hash, the attacker could update both at once. Anyone who downloaded the ISO, ran sha256sum, and compared the output to the value on linuxmint.com passed the check. The check was meaningless.

That incident is the cleanest illustration I know of why a checksum alone does not prove a download is genuine. A hash protects against accidental corruption and against tampering by anyone who cannot also change the published hash. It does not protect against an attacker who controls the publisher. The fix has existed since the 1990s. It is signing the hash with a key the user trusts, separately from the page where the file lives. Most people who run sha256sum every day have never gone the rest of the way, and most projects have never made it easy to. This post walks through what each layer of verification actually proves, the incidents that broke ordinary checksum workflows in practice, and how to do this properly on a Tuesday afternoon without becoming a cryptographer.

What a Checksum Actually Proves (and What It Does Not)

Before walking through any tooling, it is worth being precise about what the math actually buys you. A SHA-256 hash of a file proves two things. First, the file you have right now is byte-for-byte identical to the file someone hashed earlier. Second, nobody can produce a different file with the same hash. The second property is what makes the first useful. If collisions were easy to find, a matching hash would mean very little.

A SHA-256 hash of a file does not prove anything else. It does not prove that the hash you are comparing against came from who you think it did. It does not prove the file is uncompromised, official, or safe. It does not prove the publisher's infrastructure has not been tampered with. The hash is a fingerprint, not a signature. A fingerprint says "this matches the thing I previously measured". A signature says "the person I trust says this is the thing they intended".

That distinction is the entire point of this article. There are two questions you have to keep separate when you verify a download, and most of the bad outcomes I have seen come from collapsing them into one:

  • Is this file the same bytes the publisher computed the hash for? A plain sha256sum answers this.
  • Was the hash itself published by the person I trust, without anyone in the middle changing it? A plain sha256sum cannot answer this. You need a signed checksum file, and you need the public key out-of-band.

If a malicious mirror serves you a tampered ISO and the same malicious mirror publishes the SHA-256, comparing your local hash to their hash is theatre. The math is correct. The chain of trust is not. The 2016 Linux Mint compromise is exactly that scenario, only the malicious source was not a third-party mirror but the project's own homepage after a WordPress breach. If you are wondering how often that happens to the projects you care about, the honest answer is more often than anyone wants to admit, which is why the signed-checksum pattern exists.

For the record, if you only have a few seconds and you only want to detect bit-rot or a half-downloaded file, comparing a SHA-256 to the page where you got the link is fine. It catches accidents. It does not catch attackers. Keep going if you are downloading anything that will get root access on your machine.


Real Incidents Where a Checksum Was Not Enough

It is easier to remember the rules when you have seen them broken. Four incidents from the last decade, picked because each one hits a different layer of the trust chain.

Linux Mint, February 2016

The Linux Mint website was running an outdated WordPress install. Attackers got in on February 20, modified the download page to point at an ISO they had prepared, and updated the displayed SHA-256 to match. The malicious image, a Cinnamon edition of Mint 17.3, contained the Tsunami IRC bot, which gave the attackers a backdoor on every machine that installed it. The breach lasted about a day. A few hundred people downloaded the compromised ISO. The project's response was quick and honest, but the lesson stuck. Founder Clem Lefebvre wrote a postmortem the next morning. The fix on the consumer side, then and now, is to verify the GPG signature on the checksum file using a key you obtained before the compromise.

Handbrake, May 2017

Handbrake is a popular open-source video transcoder. On May 2, 2017, attackers compromised one of the project's download mirrors and replaced the macOS DMG with a trojanized version containing OSX.Proton, a remote-access trojan. The mirror also served a matching SHA-1 checksum. The compromise lasted about four days. The Handbrake team posted a warning and Apple revoked the developer certificate associated with the malicious build. Notable: the legitimate macOS build was code-signed, but a separate unsigned binary distributed via mirror got past anyone who only checked the listed hash. The takeaway: a checksum from the same compromised infrastructure as the file is worth nothing, and code-signing only helps if you actually check the signature.

CCleaner, September 2017

This one is worse, because no consumer-side check would have caught it. Attackers breached Piriform's build infrastructure between roughly August 2017 and the discovery in September, and inserted a backdoor into the CCleaner source tree. The compromised code was built and signed by Piriform's legitimate signing certificate. Approximately 2.27 million users installed an installer that passed every checksum check, every signature check, and Windows Authenticode verification. The defense for this attack does not live in checksum verification. It lives in reproducible builds (which would expose a mismatch between the public source and the released binary) and in transparency logs that record every signature publicly so that mass-distributed malware leaves a trail. Both of those exist now in production form in 2026. Neither was deployed at Piriform in 2017.

SolarWinds Orion, December 2020

The most expensive supply-chain attack of the modern era and structurally identical to CCleaner. Threat actor injected SUNBURST into the SolarWinds Orion build pipeline. Trusted, signed updates went out to roughly 18,000 customers across the US federal government, Fortune 500 companies, and global enterprises. Again, no checksum check by any customer would have detected the modification, because the modification happened upstream of the signature. The investigations that followed pushed reproducible builds and software bills of materials (SBOMs) from a niche concern into a White House executive order.

The shared pattern is worth saying out loud. Where the attacker controls the build or the signing infrastructure, no consumer-side hash or signature check helps. The defense moves to a different layer: independent verification of the binary against the public source. Where the attacker only controls a mirror, a page, or a network path, a signed checksum from a key you obtained out-of-band still saves you. That second case is by far the more common one, and it is the case that ordinary download verification is supposed to solve.


The Signed Checksum Pattern (SHA256SUMS + GPG)

Open the Debian or Ubuntu mirror directory listing for any release. You will see the ISO, a file called SHA256SUMS, and a file called SHA256SUMS.gpg (sometimes SHA256SUMS.sign or .asc). That is the pattern. Tor Browser, Tails, Bitcoin Core, Arch Linux, Fedora, Qubes OS, and pretty much every distribution that takes its threat model seriously uses some variation of it. Here is what happens at each step.

  1. The publisher computes a hash for every release file and writes one line per file to SHA256SUMS.
  2. The publisher signs SHA256SUMS with their PGP private key. The signature is published as SHA256SUMS.gpg (detached) or SHA256SUMS.asc (cleartext-signed).
  3. You download the file you want, the SHA256SUMS, and the signature.
  4. You verify the signature against a PGP public key you obtained out-of-band. Out-of-band means not from the same page or the same network channel as the file.
  5. If the signature verifies, you then check your downloaded file against the matching line in SHA256SUMS.

Two stages, two questions. The signature stage answers "did this hash list come from who I think". The hash stage answers "is the file I have what they hashed". Each stage is useless without the other. Verifying only the signature on SHA256SUMS tells you nothing about the file you downloaded. Verifying only the hash without the signature is what the Linux Mint victims did.

Concrete commands. On any Linux or macOS box with gpg installed:

# 1. Import the signing key (got from a trusted out-of-band source)
gpg --import release-signing-key.asc

# 2. Verify the signature on the checksum file
gpg --verify SHA256SUMS.gpg SHA256SUMS

# 3. Verify your file against the now-trusted checksum list
sha256sum -c SHA256SUMS --ignore-missing

Windows users need a GPG implementation. Gpg4win is the reference and bundles Kleopatra, a GUI that handles the verification step click-by-click. PowerShell 7 has Get-FileHash for step 3.

The hardest part of the whole workflow is step 1, getting the right key. The signature check will pass with any key, including a key the attacker generated and pushed to keyservers. What matters is whether the key fingerprint matches the one the project actually controls. There are several ways to bootstrap that trust, none of them perfect:

  • Fetch the fingerprint from the project website over a different network or device. Mobile data while you downloaded the file over wifi at a hotel. A friend's laptop. The printed proceedings of a security conference where the project was represented.
  • For long-lived projects, look up the fingerprint in archived snapshots on the Wayback Machine. If the same fingerprint appears in snapshots from years before the current one, an attacker who only recently compromised the site cannot rewrite that history.
  • Check the PGP web of trust if the maintainer has signatures from people you already know.
  • For some projects, the key is included in your distro's keyring already. Ubuntu ships the Ubuntu archive signing key with the OS itself. The trust is bootstrapped through the distro install image, which you presumably verified earlier.

On a first install, you usually have to trust the fingerprint published on the project's HTTPS page, and then never update the key without verifying the new key was signed by the old one. That is a slightly weaker chain than a security purist would want. It is also dramatically better than skipping the signature check entirely.

Quick hashes without a terminal: for ad-hoc SHA-256, SHA-512, or MD5 of a pasted string or short snippet (not a file on disk), the StackConvert hash generator runs entirely in your browser. Useful for double-checking the format of a published hash or computing one for test data without leaving the page.


Verifying a Real Download End-to-End: Tor Browser

Tor Browser is a good worked example. The threat model is real, the project documents the verification well, and an attacker who can swap your Tor download has access to your browsing for the lifetime of that install. Here is what a clean verification looks like in 2026.

Step 1. Download Tor Browser for your platform from torproject.org/download/. Note that the modern Tor download page links a detached signature (.asc) next to each binary.

Step 2. Download the signature file with the same filename plus an .asc extension. For example, tor-browser-linux-x86_64-14.0.tar.xz pairs with tor-browser-linux-x86_64-14.0.tar.xz.asc.

Step 3. Get the Tor Browser developers' signing key. The fingerprint is published on the Tor Project's support documentation. As of 2026 it is EF6E 286D DA85 EA2A 4BA7 DE68 4E2C 6E87 9329 8290. Do not trust me on that. Get it from torproject.org over a different network than the one you used for the download. The Tor docs also include a script that fetches the key from keys.openpgp.org, but a keyserver alone will accept any uploaded key, so the fingerprint cross-check is what makes the import safe.

gpg --auto-key-locate nodefault,wkd --locate-keys [email protected]

Step 4. Verify the signature on the file.

gpg --verify tor-browser-linux-x86_64-14.0.tar.xz.asc tor-browser-linux-x86_64-14.0.tar.xz

If the signature is valid you will see something like gpg: Good signature from "Tor Browser Developers (signing key) <[email protected]>". The output will also warn that the key is not certified with a trusted signature. That warning is normal unless you have manually edited the trust on the key. The fingerprint shown in the gpg output is what you cross-reference against the value from step 3.

If you see a "BAD signature" line or a missing public key error, do not extract the archive. Re-download from a fresh session, ideally on a different network, and check the fingerprint again. A failed Tor verification is one of the few situations in computing where I think the right response is to stop and start over rather than to push through.


When the Hash Does Not Match: Troubleshooting

If you have run sha256sum, looked at the output, and the value does not match the published one, the most likely explanation is boring. Here is what to check, roughly in order of how often I have seen each one.

  • You compared the wrong hash. Easy to mix MD5 and SHA-256 outputs at a glance. Re-run with the algorithm the publisher actually used. Also watch for hex vs base64 encodings - Subresource Integrity hashes are base64, OS tools usually output hex.
  • The file was corrupted in transit. Browsers, proxies, antivirus, and CDNs occasionally truncate or modify downloads. Re-download with curl -O over a clean HTTPS connection. For very large files (above 4 GB), some older download tools still cap the file size silently.
  • Line-ending conversion (text files only). Git for Windows, a few archive tools, and the occasional FTP client convert LF to CRLF on extract. If the publisher hashed the file with LF line endings, your CRLF copy will hash differently. Disable autocrlf or grab the file in binary mode.
  • You hashed the wrong file. More common than I want to admit when downloading multi-file archives. Quick sanity check: ls -la and confirm the file size matches what the publisher lists.
  • Encoding artifacts. Saving a page via "Save Page As" in a browser can inject a BOM or change the encoding. Use curl or wget for any file you intend to verify.
  • The publisher is wrong. Rare, but it happens. A maintainer publishes a fix but forgets to update the listed hash. Cross-check against a different mirror or the project's git tag. If the project uses signed checksums, the signature will fail with the same error - that is your hint to file an issue.
  • The file has actually been tampered with. If you have eliminated everything else and the hash still does not match, do not run the binary. Re-download from the official source over a different network and ideally a different device. Report the discrepancy to the project. This is the rare case, but it is the reason you check in the first place.

One other thing worth saying. If you only have a hash and no signature, and the hash does not match, you still cannot tell which of the seven possibilities above you are looking at. That is part of why the signed-checksum workflow is so much more useful: a failed signature is a categorically different signal from a failed hash.


Beyond Hashes: Code Signing, Sigstore, Reproducible Builds

The SHA256SUMS + GPG pattern is the workhorse, but it is not the only layer. Three other tools cover gaps it leaves.

Platform code signing

Windows Authenticode and Apple's codesign embed a signature directly into the binary. The OS checks the signature at launch time without you doing anything. This is the only verification layer that actually reaches typical end users, because it requires zero effort. The flip side: trust roots are controlled by Microsoft and Apple, and certificates have been stolen or mis-issued more than once. CCleaner 2017 is the canonical reminder that "signed" is not the same as "uncompromised". For your own verification on a downloaded installer:

# Windows (PowerShell)
Get-AuthenticodeSignature .\installer.exe | Format-List

# macOS
codesign -dv --verbose=4 /Applications/Some.app
spctl --assess --verbose /Applications/Some.app

Sigstore and Rekor

Started inside the Linux Foundation in 2021, Sigstore is a younger system that fixes two old PGP pain points. First, the signing identity is tied to a short-lived token from an OIDC provider (your GitHub or Google account at the time of signing), so projects do not need to manage long-lived signing keys at all. Second, every signature is recorded in a public transparency log called Rekor. If a maintainer ever signs something malicious, the signature shows up in a public, append-only log that anyone can audit. This is the part that catches a CCleaner-style attack where the legitimate publisher signs malware: the malicious signing event would be visible to the world the moment it happened.

Practical tools: cosign for container images and arbitrary blobs, gitsign for git commits. Adoption is strong in cloud-native (Kubernetes images, most CNCF projects) and growing slowly elsewhere.

Reproducible builds

The idea is that anyone with the source code should be able to build the released binary and get bit-for-bit identical output. If multiple independent builders all produce the same hash from the same source, you have strong evidence the published binary matches the public code. Debian started this push in 2013 and now reports that about 97% of their package archive builds reproducibly. Bitcoin Core requires multiple independent reproducible-build signatures before a release is announced. Tor Browser is reproducibly built. The remaining hard parts are mostly timestamps, locale-dependent output, parallel compilation order, and embedded build paths. The diffoscope tool exists specifically to diff two builds at every layer down to the bytes and show where they diverge.

For an average user, the practical takeaway is that code signing happens automatically and is your daily layer of defense. For projects you depend on heavily, look for reproducible build verification by a third party. For your own pipelines, look at Sigstore - generating short-lived signing keys instead of managing a long-lived PGP key is a real reduction in operational risk.


Automation: Verifying Downloads in CI

Most of the verifications in this post sound manual. In CI they should not be. Here is how the modern ecosystems already do it for you, and how to wire it up when they do not.

sha256sum -c in shell scripts

The simplest pattern. Drop the expected hash in a checked-in file next to your download step:

# expected.sha256 (committed to the repo)
3a7bd3e2360a3f06d3...  artifact.tar.gz

# In your script
curl -fsSL https://example.com/artifact.tar.gz -o artifact.tar.gz
sha256sum -c expected.sha256

Non-zero exit on mismatch. The pipeline fails. The expected hash is in git history, so every change to it is reviewable in a PR.

Package manager integrity fields

Almost every modern package manager already does this for you. The list is worth knowing because it affects how you reason about supply-chain risk in your own projects.

  • npm. Since npm 5, package-lock.json stores a Subresource Integrity hash per package. npm refuses to install if the registry serves a different file. Format is sha512-<base64hash>.
  • pip. --require-hashes mode plus --hash=sha256:<hex> annotations on every line in requirements.txt make pip refuse to install anything that does not match.
  • cargo. Cargo.lock records a SHA-256 of every crate version. Mismatches abort the build.
  • Go modules. go.sum records hashes for every module version, cross-checked against the global Go checksum database at sum.golang.org.
  • apt and dnf. Both verify GPG signatures on every package and on the repository metadata files. A repository that publishes packages without valid signatures fails the install by default.

The risk shifts to the moment you add a new dependency. Once it is in your lock file, the hash is pinned. Audit the diff carefully when a PR adds or bumps a dependency, because that is where supply-chain attacks tend to land in 2026.

A GitHub Actions example

- name: Download installer
  run: curl -fsSL https://example.com/installer.bin -o installer.bin

- name: Verify installer hash
  run: |
    EXPECTED="3a7bd3e2360a3f06d361b2c41028e4bb..."
    echo "$EXPECTED  installer.bin" | sha256sum -c -

Update the expected value via PR when the upstream version bumps. It is mildly annoying. That is also the point - it makes silent dependency drift visible.


A Practical Verification Checklist

Tailored to what you are downloading, because the cost of each step is different and so is the threat model.

OS ISOs (Ubuntu, Debian, Fedora, Arch, Tails)

  • Always do the full SHA256SUMS plus GPG signature workflow.
  • Cross-check the signing key fingerprint from a different network or device than the one you used to download the ISO.
  • Verify the signature, then verify the file against the line in SHA256SUMS.

Tor Browser, Bitcoin Core, security-critical apps

  • Same as above. No shortcuts.
  • For Bitcoin Core, prefer to verify the reproducible-build signatures from the bitcoin-core/guix.sigs repository, not just the maintainer's signature on the release.

Installers from a vendor (Slack, Zoom, JetBrains, drivers)

  • If the platform code-signs the installer, check the signature once.
  • Windows: Get-AuthenticodeSignature .\file.exe. macOS: codesign -dv --verbose=4 file.dmg.
  • If no signature, at least confirm the HTTPS connection has no warnings and use the official URL, not a third-party mirror.

Scripts and binaries from GitHub releases

  • Check the release page for a SHA-256 or signature artefact.
  • If the project signs commits or releases through Sigstore, verify with cosign verify-blob.
  • For unsigned scripts, pin to a specific commit hash and re-verify when you bump the version.

Pinned dependencies in CI

  • Always pin by version and hash. Use the language ecosystem's lock file (npm, pip, cargo, go).
  • Run a periodic audit step (npm audit, pip-audit, cargo audit).
  • Treat lock-file diffs in PRs as security-sensitive review territory, not as noise.

The depressing truth is that most people will continue downloading installers and double-clicking them without checking anything. That is fine for low-stakes downloads from established sites with valid HTTPS certificates. It is not fine for OS ISOs, cryptocurrency wallets, security software, or anything that touches the kernel of a machine you care about. The two extra minutes to verify a GPG signature on those downloads is the cheapest insurance in computing. If you take one habit away from this post, make it this one: when something is about to get root, verify the signed checksum.


Frequently Asked Questions

Is HTTPS not enough?

HTTPS protects the transport. It tells you the server you are talking to is the one named in the certificate. It does not tell you the server is uncompromised, the file was not modified on the server, or the certificate was not mis-issued by a CA. Verification operates one layer above HTTPS, on the contents of the file. Both are necessary; neither is sufficient on its own.

Why do not more projects sign their releases?

Friction. PGP is famously hard to use. Key management is operationally painful, keyservers have been unreliable, and signing workflows tend to live outside the build pipeline as an extra manual step. Sigstore was designed specifically to remove most of that friction by replacing long-lived keys with short-lived OIDC-bound certificates, and adoption is growing in cloud-native projects. Expect this gap to narrow significantly over the next few years.

Can I verify a Windows .exe without installing GPG?

Yes, for Authenticode-signed binaries. Right-click the file, Properties, Digital Signatures tab, or run Get-AuthenticodeSignature in PowerShell. For PGP-signed downloads, you need a GPG implementation; Gpg4win bundles Kleopatra and the command-line tools. For raw SHA-256 file checks, PowerShell ships with Get-FileHash built in.

What if the project only publishes an MD5?

Treat it as a corruption check only, not a tampering check. MD5 collisions are trivial in 2026 - chosen-prefix collisions have been demonstrated since 2008 and are part of how the Flame malware forged Microsoft certificates. If the project still publishes only MD5, ask them to add SHA-256, or get the file from a different source. For background on why MD5 is no longer acceptable for security purposes, the MD5 vs SHA-256 vs SHA-512 comparison walks through the cryptographic timeline.

Do package managers handle this for me?

Mostly yes. apt, dnf, brew, npm, pip, cargo, go - all of them verify hashes (and sometimes signatures) on packages they fetch. The risk shifts to the moment you add a new dependency. Once it is in your lock file, the hash is pinned and any silent change will fail the build. Audit who and what you trust to add new entries to your lock file.

How do I generate hashes for a string or a piece of test data?

For files on disk, use the native OS tools - Get-FileHash on Windows, shasum -a 256 on macOS, sha256sum on Linux. For ad-hoc text or short test strings, the StackConvert hash generator runs entirely in the browser and supports MD5, SHA-1, SHA-256, and SHA-512 in one place. The hash generator walkthrough goes through the tool step by step and covers the OS-native commands as well.

What is a "good signature" warning telling me when I run gpg --verify?

"Good signature" means the cryptographic math checked out: the signature was produced by the private key matching the public key in your keyring. The warning that usually follows ("This key is not certified with a trusted signature") is telling you that gpg cannot prove the public key actually belongs to who it claims to belong to - it is just a key with that name on it. The fingerprint cross-check from a different channel is what turns a technically valid signature into a meaningfully verified one.

What is the difference between SHA256SUMS.gpg and SHA256SUMS.asc?

Both are signatures on the SHA256SUMS file. .gpg is a detached binary signature, smaller and slightly faster to verify. .asc is the same thing in ASCII-armored form, sometimes used as a cleartext-signed file that contains both the signed content and the signature in one document. Either works; check whichever your project publishes. The verification command is the same: gpg --verify SHA256SUMS.gpg SHA256SUMS or gpg --verify SHA256SUMS.asc.

How often do real attackers actually target the download chain?

More often than people think and less often than the security press suggests. Mass-distribution attacks like CCleaner and SolarWinds are rare but devastating when they happen. Single-mirror compromises and DNS-poisoning attacks against unverified downloads are common enough that every major distro treats signed checksums as table stakes. If you are downloading something that crosses a security boundary - a fresh OS install, a wallet, a VPN client, a kernel driver - the signed-checksum step is worth the two minutes.