Download files with cURL and verify SHA-256 checksums
Use cURL to download a file and sha256sum to check its bytes against a trusted checksum. The Bash
script below keeps the download in a temporary directory and gives it its final filename only after
verification succeeds. You can run the same script locally and in GitHub Actions.
Choose a trusted checksum
SHA-256 produces a 256-bit digest, usually written as 64 hexadecimal characters. A matching digest checks the downloaded bytes against the expected value. It does not establish who supplied that value: someone who can replace both a download and its checksum can make them agree.
Obtain the expected checksum from the publisher’s release metadata over HTTPS and keep it with the versioned URL you intend to download. If you need to authenticate the release independently of its hosting site, follow the publisher’s signature-verification procedure using a signing key whose identity you have independently established. The cURL project provides detached signatures for its release archives; the checksum example here does not verify those signatures.
Automate verification with Bash
Use Linux with Bash, a cURL build that supports HTTPS, and GNU coreutils: sha256sum, mktemp, ln,
and rm. The destination is the current directory, which must be writable and on a filesystem that
supports hard links. This uses GNU ln -T; it is not a portable /bin/sh or macOS script.
The examples were tested with Bash 5.2.21, cURL 8.5.0, and coreutils 9.4 on Ubuntu 24.04, and with
Bash 5.3.15, cURL 8.22.0, and coreutils 9.11 on Linux.
Save this as verify-download.sh in a directory you control. Run it with Bash as shown below; do not
source it into your shell. The three arguments are the HTTPS URL, the expected SHA-256 digest, and a
local filename. Output names may contain spaces, leading hyphens, or literal % characters, but
must not contain a slash or line break. An existing file, directory, or symlink is an error and is
left untouched.
#!/usr/bin/env bash
set -euo pipefail
export LC_ALL=C
if [[ $# -ne 3 ]]; then
printf 'Usage: bash verify-download.sh HTTPS_URL SHA256 OUTPUT_NAME\n' >&2
exit 2
fi
url=$1
expected=$2
output=$3
if [[ $url != https://* || ! $expected =~ ^[[:xdigit:]]{64}$ ]]; then
printf 'Provide an HTTPS URL and a 64-digit hexadecimal SHA-256 checksum.\n' >&2
exit 2
fi
case "$output" in
''|.|..|*/*|*$'\n'*|*$'\r'*)
printf 'Provide a filename without slashes or line breaks.\n' >&2
exit 2
;;
esac
if [[ -e "./$output" || -L "./$output" ]]; then
printf 'Destination already exists: %s\n' "$output" >&2
exit 1
fi
umask 077
temp_dir=$(mktemp -d ./.verify-download.XXXXXX)
trap 'rm -rf -- "$temp_dir"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
curl -q -fsSL --globoff --proto '=https' --proto-redir '=https' \
--connect-timeout 10 --max-time 120 --retry 3 \
--output "$temp_dir/payload" --url "$url"
if ! printf '%s %s\n' "$expected" "$temp_dir/payload" | sha256sum --check --status -; then
printf 'SHA-256 mismatch or unreadable download.\n' >&2
exit 1
fi
ln -T -- "$temp_dir/payload" "./$output"
printf 'Verified: %s\n' "$output"
The cURL options make HTTP errors such as a 404 fail (-f),
hide the progress meter while retaining error messages (-sS), and follow redirects (-L). Both
the original request and redirects are restricted to HTTPS. --globoff treats the URL literally;
-q, placed first, prevents a local .curlrc from changing the request. Transient failures get up
to three retries, with a 120-second limit per transfer attempt.
The script constructs one checksum record: the digest, two spaces, and the private payload path.
GNU sha256sum --check
reads that record from standard input. --status makes the exit status decide whether verification
succeeded. No remote checksum file gets to choose which local filenames are checked.
After a match, GNU ln
adds the final name to the already verified file. -T treats the destination as an exact name,
including when a directory exists there, and the absence of -f prevents replacement. Keeping the
temporary directory beside the destination puts both names on the same filesystem. The exit trap
removes the temporary name and directory; the verified file remains at its final name.
Download or checksum failures return nonzero and remove temporary data without creating the final
file. Reruns start a fresh download; they do not resume partial bytes. The traps also handle normal
interrupts, but a power failure or SIGKILL can leave a .verify-download.* directory for manual
cleanup. This is a download into a directory you own, not protection against another process that
can modify your files.
Verify a cURL release
The cURL 8.22.0 release includes
curl-8.22.0.tar.gz. Its
release metadata lists that exact
asset’s digest as
sha256:d54dd598bf05927a726deb38df31c6a255ba83ff1de57c5d1464dac3ed8f44a1.
Use the 64 characters after sha256: as the expected value. The .tar.xz and .zip assets have
different bytes and checksums.
This is JSON metadata, not a sha256sum --check input file. Do not guess a checksum URL by appending
.sha256 to a download URL: curl-8.5.0.tar.gz.sha256 has no file at that address. Here we pin the
digest published for the release asset and download the archive from
curl.se:
bash verify-download.sh \
'https://curl.se/download/curl-8.22.0.tar.gz' \
'd54dd598bf05927a726deb38df31c6a255ba83ff1de57c5d1464dac3ed8f44a1' \
'curl-8.22.0.tar.gz'
On success, the command exits with status zero and prints:
Verified: curl-8.22.0.tar.gz
The archive is now in your current directory. This downloads source code; it does not install or upgrade cURL. To repeat the example, use a different output name or deliberately remove the previous download first. To verify another release, select its exact asset and update both the URL and digest.
GitHub Actions integration
Keep verify-download.sh in your repository root and save this workflow as
.github/workflows/verify-download.yml. It uses the same pinned URL and digest. The final step
lists the verified archive with Ubuntu’s tar and gzip; substitute your build step there once the
check works.
name: Verify download
on: [push, pull_request]
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Download and verify
shell: bash
run: |
bash verify-download.sh \
'https://curl.se/download/curl-8.22.0.tar.gz' \
'd54dd598bf05927a726deb38df31c6a255ba83ff1de57c5d1464dac3ed8f44a1' \
'curl-8.22.0.tar.gz'
- name: Read verified archive
shell: bash
run: tar -tzf curl-8.22.0.tar.gz > /dev/null
GitHub Actions stops subsequent steps after a failure by default.
Keep that behavior: adding continue-on-error or a success-forcing || true would allow a later
step to proceed after verification failed. Review checksum changes alongside dependency updates;
calculating an “expected” digest from the freshly downloaded file would only compare it with itself.
Troubleshooting
- HTTP error, including
curl: (22): check the exact asset URL. An error page must not become your release archive. A redirect to HTTP is also rejected, even if the destination is reachable. - Partial transfer, such as
curl: (18): the server closed the response before its advertised length arrived. Retry after checking the connection. If the server reports a shorter file as a successful response, the checksum comparison still rejects the changed bytes. - SHA-256 mismatch: check the release version and archive extension against the publisher’s metadata. Do not replace the expected checksum with the value of the suspect download.
- Destination already exists: the previous file is preserved. Choose another local name or inspect and remove the previous download yourself.
- Hard-link or write error: use a writable local filesystem that supports hard links. A failed publication step returns nonzero even if the checksum matched.
