Hash Notes — A Hashing & Cryptography Field Guide

Passwords & Authentication

HMAC & Signed Tokens

A hash with a secret key mixed in — proving a message came from someone holding the key and wasn't altered. Behind webhook signatures, JWTs, signed URLs, session cookies, and API request signing.

A plain hash proves data hasn’t changed — but anyone can compute a hash, so an attacker who changes the data just recomputes it. A MAC (message authentication code) fixes that by mixing in a secret key: only someone with the key can produce a valid tag, and only someone with the key can check it.

HMAC is the standard way to build a MAC from a hash function: HMAC-SHA256(key, message) → 32 bytes.

Fig. 1 — Webhook signing — tamper with the body and the signature stops matching

Sender

Body sent

{"event":"payment.succeeded","order_id":"ord_8812","amount":4999}

Header X-Signature: HMAC-SHA256(secret, body)

Receiver — try editing the body (an attacker in the middle) or the secret

Receiver recomputes HMAC-SHA256(secret, received body)

Signature mismatch — reject the request. Either the body was altered in transit or the sender doesn't know the secret. An attacker who changes amount can't fix the signature without the key.

What HMAC gives you

  • Integrity — any change to the message changes the tag.
  • Authenticity — the tag could only have been produced by a holder of the key.
  • Not confidentiality — the message itself is sent in the clear. Encrypt separately if it’s secret.
  • Not non-repudiation — both sides hold the same key, so either could have produced the tag. If you need to prove which party signed, use a digital signature (public/private key).

Verifying correctly

import hmac, hashlib

def verify_webhook(secret: bytes, body: bytes, header_sig: str) -> bool:
    expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header_sig)   # constant-time
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyWebhook(secret, rawBody, headerSig) {
  const expected = createHmac("sha256", secret).update(rawBody).digest();
  const given = Buffer.from(headerSig, "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}

Three details that cause real bugs:

  1. Verify the raw bytes. Sign/verify the body exactly as received. If your framework parses JSON and you re-serialize it, key order or whitespace changes and every signature fails.
  2. Compare in constant time. == stops at the first differing byte, so response time leaks how many leading bytes matched. An attacker can discover a valid signature byte by byte. Use hmac.compare_digest, crypto.timingSafeEqual, MessageDigest.isEqual.
  3. Include a timestamp and reject old ones. Otherwise an attacker can replay a captured, validly-signed request forever. Stripe signs timestamp + "." + body and rejects anything older than 5 minutes.

Where HMAC shows up

Use How
Webhook signatures Stripe, GitHub, Shopify, Slack: HMAC-SHA256(endpoint_secret, timestamp + body) in a header.
JWT (HS256) HMAC-SHA256(secret, base64url(header) + "." + base64url(payload)) is the third segment.
Signed URLs S3 pre-signed URLs and CDN tokens: HMAC over path + expiry; the server verifies without a DB lookup.
Session cookies Flask, Express cookie-signature, Rails: value.HMAC(value) so users can’t edit their cookie.
API request signing AWS Signature V4: an HMAC chain over the canonical request, so the secret key is never sent.
Pseudonymizing PII HMAC(key, email) gives a stable join key that can’t be brute-forced without the key — unlike plain sha256(email).
Key derivation HKDF (used in TLS 1.3, Signal) derives many keys from one secret using HMAC.

JWTs specifically

A JWT is three Base64url segments: header.payload.signature. The payload is readable by anyone — only the signature is protected.

Storing API keys and tokens: hash them

Long, random, server-generated secrets — API keys, password-reset tokens, session IDs, refresh tokens — should be stored hashed (so a DB leak doesn’t hand out working credentials), but unlike passwords they don’t need a slow hash: 256 random bits can’t be brute-forced no matter how fast the hash is. A plain SHA-256 (or HMAC with a server key) is right, and it keeps lookups fast and indexable:

token = secrets.token_urlsafe(32)                       # give this to the user once
db.save(token_hash=hashlib.sha256(token.encode()).hexdigest())

# on each request
row = db.find(token_hash=hashlib.sha256(presented.encode()).hexdigest())

GitHub-style keys also add a readable prefix (ghp_, sk_live_) and a short checksum, so secret scanners can detect leaked keys in public code.