HMAC (hash-based message authentication code) takes a secret key and a message and returns a short tag. Anyone who holds the same key can compute the tag again and compare; anyone without the key cannot produce a valid tag for a changed message. That is the whole job: it proves that a message was produced by someone holding the key and was not altered on the way. The construction is defined in RFC 2104 (1997) and standardized again as FIPS 198-1. Webhook signatures, API request signing, signed cookies, JWTs with HS256 and one-time passwords all run on it.

This guide follows one calculation through by hand, explains the design choices, and then turns to the part where most bugs live: verifying signatures from real platforms. Every value below can be reproduced in the HMAC SHA256 Generator, and the code blocks are run by the site’s tests.

The Construction, Step by Step

RFC 2104 writes HMAC as:

HMAC(K, m) = H( (K0 XOR opad) || H( (K0 XOR ipad) || m ) )
  • H is the hash function, with block size B (64 bytes for SHA-1 and SHA-256, 128 bytes for SHA-384 and SHA-512) and output length L (32 bytes for SHA-256).
  • K0 is the key made exactly B bytes long: a key longer than B is hashed first, and a shorter key is padded with zero bytes.
  • ipad is the byte 0x36 repeated B times; opad is 0x5c repeated B times.
  • || is concatenation.

So HMAC hashes twice. The inner hash mixes the key with the message; the outer hash mixes the key again with the inner result. Take test case 2 from RFC 4231: key Jefe, message what do ya want for nothing?, SHA-256. The key is 4 bytes, so K0 is 4a 65 66 65 followed by 60 zero bytes. XOR with 0x36 gives 7c 53 50 53 36 36 …; XOR with 0x5c gives 16 39 3a 39 5c 5c …. The rest is two SHA-256 calls:

import { createHash, createHmac } from 'node:crypto';

const key = Buffer.from('Jefe');
const msg = Buffer.from('what do ya want for nothing?');
const k0 = Buffer.alloc(64);           // SHA-256 block: 64 bytes
key.copy(k0);                          // short key: pad with zeros
const ipad = k0.map((b) => b ^ 0x36);
const opad = k0.map((b) => b ^ 0x5c);

const inner = createHash('sha256').update(ipad).update(msg).digest();
const outer = createHash('sha256').update(opad).update(inner).digest('hex');
console.log(inner.toString('hex'));
console.log(outer);
console.log(outer === createHmac('sha256', key).update(msg).digest('hex'));

The inner hash is a2e48586…, and the outer hash is 5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843, the value printed in RFC 4231. In the generator, choose Custom HMAC, type Jefe as the key and the sentence as the message, and the same 64 hex digits appear.

Why Not Just Hash the Key and the Message?

The obvious shortcut, SHA-256(key || message), is broken for SHA-256 and the rest of the SHA-2 family. These hashes process input block by block and output their final internal state (FIPS 180-4 Merkle–Damgård design with length padding). Someone who sees SHA-256(key || message) can continue hashing from that state and compute SHA-256(key || message || padding || extra) for any extra they choose, without knowing the key. This is a length-extension attack, and it turns a “signature” into a forgery tool.

HMAC closes that door with the outer hash: the attacker never sees the inner state, only a hash of it under a second, key-dependent prefix. RFC 2104 builds HMAC on any iterated hash and does not rely on the hash being collision resistant. That is why RFC 6151 could say in 2011 that the attacks on HMAC-MD5 “do not seem to indicate a practical vulnerability” when it is used as a MAC, even though MD5 itself is broken for collisions.

HMAC, Plain Hashes and Digital Signatures

Plain hash (SHA-256)HMACDigital signature (Ed25519, RSA)
Keynoneone shared secretprivate key signs, public key verifies
Who can create a valid taganyoneanyone with the secretonly the private key holder
Who can verifyanyoneonly secret holdersanyone with the public key
Typical usechecksums, file integritywebhooks, API signing, cookiessoftware releases, certificates

The verifier of an HMAC could also have created it, so HMAC cannot prove to a third party who sent a message. When that matters, use a signature. The Standard Webhooks specification defines both: v1 is HMAC-SHA256 and v1a is Ed25519, and it recommends the asymmetric scheme when you do not control both ends.

Choosing the Hash, the Key and the Output

  • Hash: use what the other side expects. HMAC-SHA256 is the common default. HMAC-SHA1 still appears in older protocols; NIST plans to retire SHA-1 by the end of 2030, so pick SHA-256 for new designs.
  • Key length: RFC 2104 §3 says keys shorter than L are “strongly discouraged”. For HMAC-SHA256 that means at least 32 random bytes. Keys longer than the block are allowed but are hashed down to L bytes first, so they add nothing.
  • Key encoding: a key is bytes. 0b0b…0b typed as text is 40 ASCII bytes, while the same text read as hex is 20 bytes. RFC test vectors list keys in hex; most webhook secrets are used as text, and a few platforms Base64-decode them first.
  • Truncation: RFC 2104 §5 allows keeping the leftmost bits of the output, but not fewer than half of it and not fewer than 80 bits. RFC 4231 test case 5 is the 128-bit truncation of HMAC-SHA256: a3b6167473100ee06e0c796c2955552b.

How Platforms Use HMAC

Webhook providers all compute an HMAC of the raw request body, but each adds its own details:

PlatformWhat is signedKeyHeader
GitHubraw bodywebhook secretX-Hub-Signature-256: sha256=<hex>
Stripetimestamp + . + bodywhsec_… secretStripe-Signature: t=…,v1=<hex>
Slackv0: + timestamp + : + bodysigning secretX-Slack-Signature: v0=<hex>
TwilioURL + POST parameters sorted by nameauth tokenX-Twilio-Signature (HMAC-SHA1, Base64)
Standard Webhooksid + . + timestamp + . + bodyBase64 after whsec_webhook-signature: v1,<Base64>

GitHub publishes a test pair: secret It's a Secret to Everybody, payload Hello, World!. The generator’s GitHub format gives:

X-Hub-Signature-256: sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17

Request signing uses HMAC differently. AWS Signature Version 4 derives its signing key through a chain of HMACs, where each output becomes the next key:

DateKey              = HMAC-SHA256("AWS4" + secret, "20261001")
DateRegionKey        = HMAC-SHA256(DateKey, "us-east-1")
DateRegionServiceKey = HMAC-SHA256(DateRegionKey, "s3")
SigningKey           = HMAC-SHA256(DateRegionServiceKey, "aws4_request")

With the made-up secret example-secret, the first step (key AWS4example-secret as text, message 20261001) gives:

349e6c3373ce4b76d7901e1102a6d922b80c8d0a3d1f180e9e4758843a7b931d

The second step must take those 32 bytes as the key, not the 64 characters. Switch the key encoding to hex, paste the value, and sign us-east-1:

96edd7a4586b3532817981a8f36174b721a03abe26a8975b076c479de2306ca3

Read the key as text here and every later step is wrong. The signed timestamp also limits replay: AWS rejects a request that arrives more than five minutes after its timestamp (AWS).

Verifying a Webhook Without the Usual Mistakes

Three rules cover almost every verification bug.

Hash the raw bytes. The signature covers the body exactly as it was sent. Parsing JSON and serializing it again changes spacing and key order, and some frameworks do it before your handler runs; Stripe’s troubleshooting page lists Express’s express.json() placed before the webhook route as a cause. A final line break matters too: Hello, World! with a trailing newline gives 8fde2e97… instead of GitHub’s 757107ea….

Compare in constant time. == on strings stops at the first different character, and the time it takes can leak how much of a guess was right. Use crypto.timingSafeEqual in Node.js (it throws when the lengths differ, so check them first), hmac.compare_digest in Python, or hmac.Equal in Go.

Check the timestamp. A valid signed request can be replayed. Stripe and Slack sign a timestamp with the body. Stripe’s libraries reject events more than five minutes old by default, and Slack’s guide uses the same five-minute check. Stripe may also send several v1 signatures while you roll a secret, so accept the request if any of them matches.

A Stripe-style check in Node.js that follows all three rules:

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
  const items = header.split(',').map((part) => part.split('='));
  const t = items.find(([k]) => k === 't')?.[1];
  const sigs = items.filter(([k]) => k === 'v1').map(([, v]) => Buffer.from(v, 'hex'));
  if (!t || Math.abs(now - Number(t)) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  return sigs.some((s) => s.length === expected.length && timingSafeEqual(s, expected));
}

const body = '{"id":"evt_test","object":"event"}';
const secret = 'whsec_demo_only';
const t = 1790000000;
const v1 = createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
console.log(verify(body, `t=${t},v1=${v1}`, secret, t + 60));
console.log(verify(body + '\n', `t=${t},v1=${v1}`, secret, t + 60));
console.log(verify(body, `t=${t},v1=${v1}`, secret, t + 600));

It prints true, then false for one extra byte, then false for a request ten minutes old. The same check for GitHub in Python, with GitHub’s test values:

import hashlib
import hmac

def verify_github(body: bytes, header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header)

header = "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17"
print(verify_github(b"Hello, World!", header, "It's a Secret to Everybody"))
print(verify_github(b"Hello, World!\n", header, "It's a Secret to Everybody"))

And Slack in Go, using the example from Slack’s documentation:

package main

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

func main() {
	secret := "8f742231b10e8888abcd99yyyzzz85a5"
	ts := "1531420618"
	body := "token=xyzz0WbapA4vBCDEFasx0q6G&team_id=T1DC2JH3J&team_domain=testteamnow&channel_id=G8PSS9T3V&channel_name=foobar&user_id=U2CERLKJA&user_name=roadrunner&command=%2Fwebhook-collect&text=&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT1DC2JH3J%2F397700885554%2F96rGlfmibIGlgcZRskXaIFfN&trigger_id=398738663015.47445629121.803a0bc887a14d10d2c447fce8b6703c"
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte("v0:" + ts + ":" + body))
	expected := "v0=" + hex.EncodeToString(mac.Sum(nil))
	got := "v0=a2114d57b48eac39b9ad189dd8316235a7b4a8d21a10bd27519666489c69b503"
	fmt.Println(hmac.Equal([]byte(expected), []byte(got)))
}

Finding Out Why a Signature Does Not Match

When your code says “invalid signature” for a request you know is genuine, paste the body, the secret and the received header into the HMAC generator. If the header does not match, it recomputes with the usual mistakes reversed and names the one that produces the received value. With GitHub’s header and the body pasted with a final line break, it reports:

No match. Likely cause: it matches without the final line break. The value computed from the pasted body was 8fde2e970f9163923fb1cb61bb945626ff2b4091d87e622ee3ad600160592325.

The other variants it tries are CRLF line breaks, spaces around the key, JSON that was minified or indented after it arrived, a hex or Base64 key read as text, key and message swapped, a timestamp different from the one in the header, and another hash (GitHub’s legacy X-Hub-Signature is HMAC-SHA1). The calculation stays in the browser tab; the page sends nothing.

Test Vectors for Your Own Implementation

Before trusting new HMAC code, run it against published values:

SourceKeyMessageExpected
RFC 4231 test case 10b × 20 (hex)Hi ThereHMAC-SHA256 b0344c61…2e32cff7
RFC 4231 test case 2Jefewhat do ya want for nothing?HMAC-SHA256 5bdcc146…64ec3843
RFC 4231 test case 6aa × 131 (hex)Test Using Larger Than Block-Size Key - Hash Key FirstHMAC-SHA256 60e43159…0ee37f54
RFC 2202 test case 10b × 20 (hex)Hi ThereHMAC-SHA1 b6173186…f146be00
GitHub docsIt's a Secret to EverybodyHello, World!sha256=757107ea…8b043e17
LINE docs8c570fa6dd201bb328f1c1eac23a96d8{"destination":"U8e742f61d673b39c7fff3cecb7536ef0","events":[]}GhRKmvmHys4Pi8DxkF4+EayaH0OqtJtaZxgTD9fMDLs=

Test case 6 is the one that catches implementations that forget to hash a long key. The generator passes all of them, together with every RFC 2104, RFC 2202 and RFC 4231 vector and thousands of random inputs compared with Node.js and Python.