Skip to main content

Hashing: Checksums, Signed Releases, HMAC and Passwords

Hash functions for DevOps: SHA-256 and BLAKE3, verifying releases with checksums, GPG and Sigstore, content digests, HMAC webhooks and Argon2id passwords.

TLDR: A hash turns any input into a short fingerprint. Use SHA-256 (or SHA-512, SHA-3, BLAKE2, BLAKE3) for integrity, never MD5 or SHA-1 where an attacker is involved. A checksum only proves a download matches the checksum file; verify a signature on that file (GPG or Sigstore) to prove who published it. Git commits and container images are named by hashes, so pin by digest. Use HMAC with a secret key to authenticate messages such as webhooks, and compare with a constant-time function. Store passwords with Argon2id (or bcrypt or scrypt), never with a fast hash.

A cryptographic hash function takes input of any size and returns a fixed-size digest. It is a one-way function: you cannot get the input back from the digest. Unlike encryption, there is no key and nothing to decrypt. DevOps work uses hashes all the time: checksums on downloads, image digests in Kubernetes manifests, Git commit IDs, cache keys, webhook signatures and password databases.

In this part you verify a real release with a checksum, a GPG signature and a Sigstore signature, compute a Git object ID and a container image digest by hand, sign and check a webhook payload, and measure why passwords need a slow hash.

Prerequisites

  • The lab tools from the guide overview: coreutils, OpenSSL, GnuPG, Git, curl, Python 3 with argon2-cffi and bcrypt, and cosign for the Sigstore step.
  • Internet access for the download steps.
  • Part 1 is helpful for the section on signatures, but not required.

The outputs are real, from Ubuntu 26.04 LTS on a Raspberry Pi 4 (GnuPG 2.4.8, Git 2.53.0, cosign 3.1.4, Python 3.14 with argon2-cffi 25.1.0 and bcrypt 5.0.0). The guess rates were measured on that small ARM board; faster hardware gives higher rates.

What a Hash Function Guarantees

Property Meaning Why you care
Deterministic The same input always gives the same digest Anyone can recompute and compare
Fixed size 32 bytes for SHA-256, whatever the input Cheap to store, compare and publish
Pre-image resistance Given a digest, you cannot find an input for it Digests do not reveal the data (if it is not guessable)
Second pre-image resistance Given an input, you cannot find another with the same digest Nobody can swap a file for a different one
Collision resistance You cannot find any two inputs with the same digest Signatures over digests stay meaningful

A one-character change gives a completely different digest:

These two digests are the same on every machine, so you can check them yourself. Note the caveat in the pre-image row: a hash does not hide guessable data. Hashing an email address, a phone number or a short password gives no privacy, because an attacker can hash every candidate and compare.

Which Hash Functions to Use

Algorithm Digest size Status Where you meet it
SHA-256 256 bits Recommended Checksums, TLS, certificates, container digests, Git (SHA-256 repos)
SHA-384 / SHA-512 384 / 512 bits Recommended Certificates, higher security margins; SHA-512 is fast on 64-bit CPUs
SHA-3 (SHA3-256) 256 bits Recommended A different design from SHA-2, as a backup standard
BLAKE2 / BLAKE3 variable Recommended b2sum (BLAKE2b), WireGuard (BLAKE2s), b3sum, fast file hashing
SHA-1 160 bits Broken for collisions Classic Git object IDs (with collision detection), old signatures
MD5 128 bits Broken Legacy checksums; never for anything an attacker can influence

SHA-1 collisions became practical in 2017 (the SHAttered PDFs) and chosen-prefix collisions in 2020, which is the kind used to forge certificates and signatures. MD5 collisions take seconds on a laptop. For accidental corruption either still "works", but there is no reason to choose them: SHA-256 is fast everywhere and available everywhere.

Verifying Downloads: Checksums Versus Signatures

Most projects publish a checksum file next to each release. A checksum proves that what you downloaded matches the checksum file. It does not prove who made either of them: if an attacker controls the download server or the release page, they replace both. To know the file came from the publisher, verify a signature over it, made with a key or identity you trust independently of the download.

A Sigstore-Signed Release

Sigstore signs with short-lived certificates tied to an identity, such as a GitHub Actions workflow, and records every signature in a public transparency log (Rekor). There is no long-lived signing key to steal, and you verify who signed instead of which key. The step CLI used in Part 4 is released this way, so verify it before you install it:

The identity flags are the important part. cosign refuses keyless verification without an expected identity and issuer, but a loose pattern (such as --certificate-identity-regexp '.*') would accept a valid signature from anyone. Take the expected identity from a source you trust independently of the download, such as the project's release documentation; reading the certificate in the bundle only shows what the bundle claims. If a project does not document it, review the identity once on a release you trust, then pin it as above. The signing certificate in this bundle was valid for only ten minutes; the transparency log proves the signature was made during that window, which is why such short-lived certificates work.

A GPG-Signed Checksum File

Many projects, including HashiCorp and the Linux distributions, sign the checksum file with a long-lived OpenPGP key. Here you check Terraform's SHA256SUMS file against HashiCorp's key. The fingerprint is the part you must compare out of band: HashiCorp publishes it on its security page. The commands reuse $ARCH from the previous block.

"Good signature" plus a matching fingerprint is the result you want. The warning that the key is not certified only means you have not signed HashiCorp's key in your own web of trust; you established trust by comparing the fingerprint instead. Only then does sha256sum -c mean something: the zip matches a checksum file that HashiCorp signed. The throwaway keyring lives in $GNUPGHOME; remove it with rm -rf "$GNUPGHOME" when you are done.

Tip

In CI, pin the expected checksum or signer identity in the pipeline itself (or use a package manager that does, such as apt with signed repositories or go.sum), so a compromised download server cannot change both the file and its checksum.

Content Addressing: Git and Container Images

When a system names data by its hash, the name proves the content. Git and OCI registries both work this way.

Git Object IDs

A Git object ID is the hash of a short header (blob, the size, a NUL byte) followed by the content. You can reproduce it with sha1sum and sha256sum:

Commits hash their tree, parents, author and message, so one commit ID pins the whole history behind it. New repositories still use SHA-1 by default, but Git has used a hardened SHA-1 that detects the known collision attacks since version 2.13 (2017). SHA-256 repositories work since Git 2.29, and Git 3.0 plans to make them the default (it has no release date yet). SHA-1 and SHA-256 repositories cannot exchange objects yet, and not every hosting service accepts SHA-256 repositories, so check yours before you switch.

Container Image Digests

A registry names each manifest by the SHA-256 of its exact bytes. Tags such as 3.10 or latest are movable labels; a digest cannot change. Fetch a manifest list and hash it yourself:

The two values match. Reference images by digest in production manifests (registry.k8s.io/pause@sha256:...), and the image cannot be swapped behind the tag. Tools such as Renovate and Dependabot keep digest pins up to date.

HMAC: Authenticating Messages

A plain hash proves nothing about who made a message: anyone can recompute it. An HMAC mixes a secret key into the hash, so only holders of the key can produce or check the tag. This is how GitHub, Stripe, Slack and most other services sign webhooks, how AWS Signature Version 4 signs API requests, and how many token formats (such as JWT with HS256) work.

GitHub sends X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the body>. Compute one with OpenSSL:

Then check it the way your webhook handler should. Save this as verify_webhook.py in ~/crypto-lab/part2:

import hashlib
import hmac

SECRET = b"lab-webhook-secret"  # in production, load it from your secrets manager

def sign(body: bytes) -> str:
    return "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()

def verify(body: bytes, header) -> bool:
    # reject a missing or malformed header instead of raising an error
    if not isinstance(header, str) or not header.isascii():
        return False
    # compare_digest takes the same time wherever the first difference is
    return hmac.compare_digest(sign(body), header)

body = open("payload.json", "rb").read()
header = sign(body)  # what the sender puts in X-Hub-Signature-256
print("header:      ", header)
print("valid body:  ", verify(body, header))
print("changed body:", verify(body.replace(b"42", b"43"), header))
forged = "sha256=" + hashlib.sha256(body).hexdigest()  # a plain hash, no secret
print("plain hash:  ", verify(body, forged))
python3 verify_webhook.py
header:       sha256=cd2de3e1693a210374eb89a9ba1f8bb2089d36e82d3530fee466d0c54889a25e
valid body:   True
changed body: False
plain hash:   False

The hex digest matches the OpenSSL output. Three rules for webhook handlers:

  1. Compute the HMAC over the raw request body, before any JSON parsing or re-serialization changes a byte.
  2. Compare with a constant-time function (hmac.compare_digest, crypto.timingSafeEqual in Node.js, hmac.Equal in Go). A plain == can return early at the first different byte, and that timing leaks how much of a guess was right.
  3. Reject old messages. An HMAC does not stop replays. Providers such as Stripe sign a timestamp together with the body; reject anything older than a few minutes and deduplicate by event ID.

Never build your own MAC as sha256(secret + message): SHA-256 and SHA-512 allow length-extension attacks on that construction. HMAC exists to avoid exactly that.

Password Storage

Never store passwords, and never store a fast hash of them. When a password database leaks, attackers guess passwords offline, as fast as their hardware allows. A password hashing function is deliberately slow and, for the best ones, memory-hard, so each guess costs real time and RAM, and a unique random salt per password stops attackers from cracking all accounts at once with precomputed tables.

Measure how many guesses per second this machine manages with each method. Save this as guess_rate.py:

import hashlib
import os
import time

import bcrypt
from argon2 import PasswordHasher

password = b"correct horse battery staple"

def rate(fn, seconds=3.0):
    n, start = 0, time.perf_counter()
    while time.perf_counter() - start < seconds:
        fn()
        n += 1
    return n / (time.perf_counter() - start)

salt = os.urandom(16)
bcrypt_salt = bcrypt.gensalt(rounds=12)
argon2 = PasswordHasher()  # Argon2id, RFC 9106 low-memory profile: t=3, 64 MiB, p=4

print(f"salted SHA-256:     {rate(lambda: hashlib.sha256(salt + password).digest()):>12,.0f} guesses/s")
print(f"bcrypt (cost 12):   {rate(lambda: bcrypt.hashpw(password, bcrypt_salt)):>12,.1f} guesses/s")
print(f"Argon2id (default): {rate(lambda: argon2.hash(password)):>12,.1f} guesses/s")
python3 guess_rate.py
salted SHA-256:          260,881 guesses/s
bcrypt (cost 12):            2.1 guesses/s
Argon2id (default):          3.9 guesses/s

The script hashes one guess at a time (Argon2id uses its default four lanes, which run as threads). Even so, this small board in Python tries about a quarter of a million salted SHA-256 guesses per second. Dedicated cracking tools on GPUs are many orders of magnitude faster, which is why leaked unsalted or fast-hashed passwords fall within hours. bcrypt and Argon2id cut that rate to a handful per second, and Argon2id's 64 MiB of memory per guess also limits how many guesses a GPU can run in parallel.

Which Algorithm and Which Parameters

The OWASP Password Storage Cheat Sheet recommends, in order:

Algorithm Minimum parameters (OWASP) Notes
Argon2id 19 MiB memory, 2 iterations, 1 lane First choice. Libraries often default higher (argon2-cffi: 64 MiB, t=3, p=4)
scrypt N=2^17, r=8, p=1 When Argon2id is not available
bcrypt cost 10 or more Legacy systems; 72-byte maximum (older libraries silently truncate, Python bcrypt 5.0+ rejects longer passwords)
PBKDF2 600,000 iterations with HMAC-SHA256 When you need FIPS-140 validated algorithms

Tune the parameters so one hash takes a few hundred milliseconds on your servers at peak login load. Use a library that stores the algorithm, parameters and salt inside the hash string, as argon2-cffi, bcrypt and most frameworks' password hashers do, so you can raise the cost later and upgrade hashes as users log in. (Raw scrypt or PBKDF2 functions, such as Python's hashlib.scrypt, return only the derived bytes; then you must store the salt and parameters yourself.) Save this as passwords.py:

import bcrypt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher()
stored = ph.hash("correct horse battery staple")
print("stored:", stored)

try:
    ph.verify(stored, "Tr0ub4dor&3")
except VerifyMismatchError:
    print("wrong password: VerifyMismatchError")

print("verified:", ph.verify(stored, "correct horse battery staple"))

# Later you raise the memory cost. Old hashes still verify, and you can
# re-hash them with the new parameters at the next successful login.
stronger = PasswordHasher(memory_cost=131072)  # 128 MiB
print("needs rehash:", stronger.check_needs_rehash(stored))

# bcrypt 5.0 refuses passwords longer than 72 bytes instead of silently cutting them
try:
    bcrypt.hashpw(b"x" * 73, bcrypt.gensalt())
except ValueError as e:
    print("bcrypt:", e)
python3 passwords.py
stored: $argon2id$v=19$m=65536,t=3,p=4$0ZnV8rqKjIpEhkjy1/M17w$YZPzySdQ3l32F8LlItum4IxjwX6r5wy8f8+L1tICHMw
wrong password: VerifyMismatchError
verified: True
needs rehash: True
bcrypt: password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])

The stored string records everything needed to verify it later: argon2id, version 19, 64 MiB of memory (m=65536 KiB), 3 iterations, 4 lanes, then the salt and the hash. Older bcrypt versions truncated long passwords silently, so two long passwords with the same first 72 bytes matched each other; bcrypt 5.0 raises an error instead. If you must accept long passphrases with bcrypt, pre-hash them with HMAC-SHA256 and a server-side key, and encode the result as Base64 so it contains no NUL bytes.

Two more rules: rate-limit and monitor login attempts (slow hashing does not stop online guessing), and support passkeys or a second factor, so a cracked password alone is not enough.

Common Mistakes

  • MD5 or SHA-1 for anything security-related: signatures, certificate fingerprints, deduplication of untrusted uploads. Use SHA-256 or better.
  • Trusting a checksum from the same place as the download. Verify a signature against an identity or key fingerprint you got elsewhere.
  • Pinning images by tag. Tags move. Pin by digest and let a bot update the digests.
  • Plain hashes as authentication. Use HMAC with a secret key, compare in constant time, and reject replays.
  • Fast hashes for passwords, even salted. Use Argon2id, scrypt, bcrypt or PBKDF2 with current parameters.
  • Hashing guessable personal data and calling it anonymous. A hash of an email address can be reversed by hashing candidates. Use a keyed HMAC (pseudonymization) or don't store it.

Summary

  • Hash functions give fixed-size, one-way fingerprints. Use SHA-256, SHA-512, SHA-3, BLAKE2 or BLAKE3, not MD5 or SHA-1.
  • Checksums detect changes; signatures (GPG or Sigstore) prove who published a file. Check both, and pin the signer's identity.
  • Git object IDs and container image digests are hashes of the content. Pin images by digest.
  • HMAC authenticates messages with a shared secret. Compute it over the raw body, compare in constant time and reject replays.
  • Store passwords with Argon2id (or scrypt, bcrypt, PBKDF2) and parameters you can raise over time.

Next, Part 3 puts encryption and hashing together in TLS, the protocol behind HTTPS.

Keep Learning

Updated:

Tags

SecurityDevSecOpsCryptographyEncryptionTLSPKIOpenSSL

Found an issue?