5 min read

Checksums, HMACs and JWTs: a field guide

Verifying downloads, checking webhook signatures, reading a JWT honestly, and the entropy math behind a good passphrase — with the Crypto & Hash app as the workbench.

cryptosecurityhashingjwthmacapps

Most working engineers touch cryptography in exactly four places: verifying that a file is the file, signing or checking a webhook, squinting at a JWT, and generating a secret. Crypto & Hash covers all four on your machine — SHA and HMAC go through the platform's WebCrypto, nothing is transmitted — so this is a tour of the actual jobs, not the theory.

Verifying a download against a published SHA-256

A project publishes terraform_1.9.5_linux_amd64.zip and next to it a SHA256SUMS file. The claim being made is narrow: the bytes you fetched are the bytes we built. Check it:

# Linux
sha256sum terraform_1.9.5_linux_amd64.zip

# macOS
shasum -a 256 terraform_1.9.5_linux_amd64.zip
# Windows
Get-FileHash terraform_1.9.5_linux_amd64.zip -Algorithm SHA256

Compare the output to the published line. For a pasted string — a release note, a config blob, a canary value — the app's Hash tab gives you MD5, CRC32 and the SHA family for the same input at once, each with a copy button, so eyeballing two hex strings becomes a diff of two clipboard pastes.

One honest caveat: if the checksum file sits on the same server as the download, an attacker who can swap the zip can swap the sums. A checksum over plain HTTP from the same host verifies transfer integrity, not authenticity. Authenticity needs the sums file to be signed (GPG, Sigstore) or fetched from somewhere the attacker doesn't control.

MD5 is dead, except where it isn't

MD5 and SHA-1 are broken for one specific property: collision resistance. An adversary can manufacture two different inputs with the same digest — for MD5 in seconds on a laptop, for SHA-1 with the SHAttered-class attacks. So any use where an attacker chooses the input is over: certificates, signatures, "verify this download".

But dedup and cache keys don't have an adversary. If you're bucketing your own log lines or detecting duplicate uploads from your own pipeline, MD5 is fine and fast, and CRC32 is fine for "did this byte get flipped in transit". The app prints both on the Hash tab with exactly this note under them: checksums and legacy interop, not security. The dividing question is always who controls the input.

HMAC: a hash with a key

A plain hash proves nothing about who computed it — anyone can hash anything. HMAC mixes a secret key into the digest, so a valid HMAC proves the sender knew the key. That's why it is the standard shape for webhook signatures.

GitHub sends every webhook with a header:

X-Hub-Signature-256: sha256=6e9b...

The value is HMAC-SHA256(secret, raw_request_body). To check one by hand, open the app's HMAC tab, paste the raw body as the message (not the re-serialized JSON — one reordered key or trailing newline changes everything), the shared secret as the key, pick SHA-256, and compare against the header minus its sha256= prefix. In production code the same check looks like:

const crypto = require("node:crypto");
const expected = "sha256=" +
  crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const ok = crypto.timingSafeEqual(
  Buffer.from(expected), Buffer.from(header));

Reading a JWT without lying to yourself

The JWT tool decodes the header and claims and works out expiry — decode only, deliberately. A JWT's first two segments are just base64url-encoded JSON; you can also prove this to yourself on the app's Encode tab by pasting a segment and decoding it as base64url. So what can you conclude from a decode?

You can conclude what the token claims: who issued it, for whom, when it expires, what scopes it names. You cannot conclude any of it is true. The signature is what binds the claims to the issuer, and checking it requires the key. Two corollaries people get wrong:

The trap: trusting alg, and comparing with ==

The JWT header announces its own algorithm. Early libraries obligingly did whatever it said — including "alg": "none", which means "no signature", which means an attacker strips the signature, sets alg to none, and forges any claims. A related classic: a server verifying with an RSA public key, fed a token whose header says HS256 — the library dutifully uses the public key as an HMAC secret, and since the public key is public, the attacker can sign valid tokens. The fix is the same in both cases: the verifier decides the algorithm and key, from its own config. The token's header is attacker-controlled input.

The second trap is quieter. Comparing an HMAC with == (or ===, or strcmp) short-circuits on the first differing byte, so the comparison time leaks how many leading bytes matched. Over enough requests, that is an oracle for forging a signature one byte at a time. Always use a constant-time compare — crypto.timingSafeEqual in Node, hmac.compare_digest in Python. If your language makes you write the loop yourself, XOR every byte and OR the results; never return early.

Entropy, or why the passphrase wins

The Generate tab mints UUIDs (v4 and v7), hex/base64 tokens up to 256 bytes, character-set passwords, and diceware-style passphrases — all from the CSPRNG, never Math.random. The interesting question is how to compare them, and the answer is one formula:

entropy_bits = length × log2(alphabet_size)

P@ssw0rd1 looks like it draws from ~70 symbols across 9 characters — 55 bits if it were random. It isn't random: it's a dictionary word with the substitutions every cracking rig tries first, worth maybe 10–20 bits in practice. A 4-word passphrase from a 7,776-word list is genuinely random over its space: 4 × log2(7776) ≈ 51.7 bits, and 5 words is 64.6. You can check the arithmetic in the command palette — type 4 * log2(7776) and it evaluates inline. The passphrase's strength survives the attacker knowing exactly how it was made, which is the only kind of strength that counts. For machine secrets, skip the debate: a 32-byte random token is 256 bits, and the Token generator's hex or base64url output is the right shape for a header or an env var.

Try it

More writing