Skip to content

HMAC Explained: Message Authentication Codes for Developers

HMAC stands for Hash-based Message Authentication Code. It is the algorithm that signs your Stripe webhooks, your AWS API requests, your GitHub push events, and basically every machine-to-machine integration where two sides share a secret and want to prove a message has not been tampered with. Understanding HMAC properly takes about ten minutes and prevents a long list of real production bugs, from missed payments because a webhook handler rejected a valid signature, to silent forgery because the handler accepted an invalid one.

Why a Plain Hash Is Not Enough

A hash like SHA-256 produces a fixed-length fingerprint of a message. If the message changes by one bit, the hash changes completely. That sounds like authentication, but it is not. Anyone who can see the message can also recompute the hash. There is no secret involved. A plain hash proves integrity only against accidental corruption, not against an attacker who can rewrite both the message and its hash.

The naive fix is to hash the message together with a shared secret: sha256(secret + message). This was actually used in the 1990s and turned out to be broken. It is vulnerable to length-extension attacks on Merkle-Damgard hashes like SHA-1 and SHA-256. An attacker who knows the original signature can append data to the message and produce a valid signature for the longer version without knowing the secret.

HMAC fixes this. It is a specific construction that wraps the hash function in two layers, with the secret mixed in twice using two different padded versions. The math is in RFC 2104. The takeaway is: never roll your own MAC by concatenating a secret with a message. Always use the HMAC primitive shipped with your standard library.

How HMAC Works (Just Enough)

Conceptually, HMAC computes:

HMAC(K, m) = H((K' XOR opad) || H((K' XOR ipad) || m))

K     = secret key
K'    = K padded or hashed to the block size of H
opad  = 0x5c repeated to block size
ipad  = 0x36 repeated to block size
H     = the underlying hash (SHA-256 in practice)
||    = concatenation

The double-hash structure neutralizes length-extension attacks. The two different pads ensure the inner and outer hashes use distinct keys, which the security proof depends on. You will never need to implement this by hand. Every language has it in the standard library.

Picking the Hash: HMAC-SHA256 by Default

The hash inside HMAC matters less than for direct hashing, because HMAC is robust even against partial weakness in H. That said, in 2026:

  • HMAC-SHA256 is the safe default. Used by Stripe, GitHub, AWS SigV4, Slack, and almost every modern API.
  • HMAC-SHA512 is a reasonable choice when you want extra margin or your key is already 512 bits.
  • HMAC-SHA1 is still deployed (older GitHub webhooks) but should not be used for new systems. SHA-1 is broken for collision resistance even though HMAC-SHA1 is not yet practically forgeable.
  • HMAC-MD5 is dead. Do not use it.

Webhook Signing: The Canonical Use Case

When Stripe sends a webhook, it includes a header like Stripe-Signature: t=1716000000,v1=abc123.... The signature is HMAC-SHA256 of the timestamp and the raw request body, keyed with a secret you share with Stripe. To verify, you reconstruct the same input on your side, recompute the HMAC, and compare. If they match, the payload is authentic. If they do not, something tampered with it or used the wrong key.

Node.js

import crypto from "node:crypto";

function verifyWebhook(rawBody, signatureHeader, secret) {
  const [tPart, v1Part] = signatureHeader.split(",");
  const timestamp = tPart.split("=")[1];
  const signature = v1Part.split("=")[1];

  const signedPayload = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");

  // Constant-time compare to defeat timing attacks.
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signature, "hex");
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Python

import hmac, hashlib

def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
    parts = dict(p.split("=") for p in signature_header.split(","))
    timestamp = parts["t"]
    signature = parts["v1"]

    signed_payload = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(
        secret.encode(),
        signed_payload,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

Go

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
)

func verifyWebhook(rawBody []byte, timestamp, signature, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(timestamp + "."))
    mac.Write(rawBody)
    expected := hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(expected), []byte(signature))
}

Constant-Time Comparison Is Mandatory

The single most common HMAC implementation bug is comparing signatures with == or strcmp. Standard string comparison short-circuits on the first differing byte, which leaks how many leading bytes were correct. An attacker who can send many requests and measure response time can recover the signature byte by byte. This is not theoretical. It has been used in real attacks against real production systems.

Always use the constant-time comparison helper from your standard library: crypto.timingSafeEqual in Node, hmac.compare_digest in Python, hmac.Equal in Go, MessageDigest.isEqual in Java.

Replay Protection

HMAC proves the message is authentic. It does not prove the message is fresh. An attacker who captures a valid signed request can replay it later and your verification will still pass. Defenses:

  • Include a timestamp in the signed payload. Reject anything older than 5 minutes. This is the pattern Stripe and AWS use.
  • Include a nonce if the same payload could legitimately be sent twice. Store nonces server-side until they expire.
  • Use TLS so attackers cannot capture the signed request in the first place. HMAC is not a substitute for transport security, it is a complement to it.

HMAC vs JWT vs Digital Signatures

  • HMAC uses a shared secret. Both sides hold the same key. Anyone with the key can both sign and verify. Best for two-party machine-to-machine integrations.
  • JWT (HS256) is literally HMAC-SHA256 wrapped in a JSON token format. Same security properties, different envelope. See our JWT guide for the full picture.
  • JWT (RS256, ES256) uses asymmetric signatures. The signer holds a private key, verifiers hold only the public key. Use this when you have many verifiers and do not want to share a secret with all of them.
  • Digital signatures (raw RSA, ECDSA, Ed25519) are the underlying primitive for the asymmetric case. They cost more CPU than HMAC but support public verification.

Common Mistakes

  • Parsing JSON before verifying. Always HMAC the raw bytes of the request body, not a re-serialized version. Reserialization changes whitespace and key order and the signature will not match.
  • Using the same key everywhere. Use a different HMAC key per environment, per integration, and per purpose. Rotate them on a schedule.
  • Logging the secret. Webhook secrets end up in stack traces and request logs more often than anyone admits. Filter them at the logger level.
  • Short keys. HMAC keys should be at least as long as the hash output: 32 bytes for HMAC-SHA256. Random bytes, not a human-typed string.
  • Forgetting case. Hex signatures may arrive uppercase or lowercase depending on the sender. Normalize before comparing, or compare the raw bytes.

TL;DR

  • HMAC = keyed hash. Proves a message came from someone with the shared secret.
  • Use HMAC-SHA256 by default. Do not invent your own MAC.
  • Sign the raw bytes, never a re-serialized version.
  • Always compare with a constant-time function. Never ==.
  • Add a timestamp to the signed payload to block replays.
  • Use long, random keys. Rotate them. Never log them.

Generate SHA-256 and other hashes in your browser

Use our free Hash Generator to compute SHA-256, SHA-512, SHA-1, and MD5 fingerprints of any text. Useful for debugging webhook signatures, comparing payload checksums, and learning how HMAC works under the hood. 100% client-side via Web Crypto API.

Open Hash Generator