Applied Cryptography for Developers Module 5 – Message authentication
HMAC and constant-time verification
Build HMAC-SHA-256 from RFC 2104, match RFC 4231 and the webhook test values GitHub publishes in Node and Python, and compare tags in constant time.
What you will learn
- Explain why a MAC needs a secret key and why SHA-256 of the key and the message is not one
- Compute and verify HMAC-SHA-256 with Web Crypto, node:crypto and the hmac module of Python
- Compare tags in constant time and reject tags of the wrong length or format without an error
- Check an HMAC implementation against RFC 4231 and a test value that a provider publishes
Before you start
What you’ll learn · Before you start
What you will learn
- Explain why a MAC needs a secret key and why SHA-256 of the key and the message is not one
- Compute and verify HMAC-SHA-256 with Web Crypto, node:crypto and the hmac module of Python
- Compare tags in constant time and reject tags of the wrong length or format without an error
- Check an HMAC implementation against RFC 4231 and a test value that a provider publishes
Before you start
On this page
A webhook arrives with a 13-byte body, Hello, World!, and the header X-Hub-Signature-256: sha256=757107ea….
Before your code acts on those bytes it has to decide whether someone holding the shared secret sent exactly them,
and it has to decide without telling a forger how close a wrong tag came.
This lesson builds that check. You will see why a tag needs a key and why a hash with the key in front is not enough, build HMAC-SHA-256 from its definition in RFC 2104, match RFC 4231’s test values and GitHub’s published one in Node.js and Python, and replace a comparison that stops at the first wrong byte with one that cannot leak. A two-step code lab at the end writes HMAC for any hash and a header check that never raises.
A tag needs a key, and a key in front of SHA-256 is not enough
Suppose the sender attached only the SHA-256 of the body. Anyone who can change the body on its way, or send a request of their own, computes the SHA-256 of the new body just as easily, because a hash has no key. A message authentication code (MAC): A function of a secret key and a message whose output, the tag, only holders of the key can compute. A receiver with the key recomputes the tag to check that the message is unchanged and came from a key holder; HMAC and KMAC are MACs. adds what is missing: a function of a secret key and the message whose output, the authentication tag: The short value a MAC or an authenticated cipher computes over a message, such as the 32 bytes of HMAC-SHA-256. The receiver accepts the message only if the tag it recomputes equals the one it received., nobody can compute without the key. The receiver holds the same key, recomputes the tag of the bytes it received and accepts them only when the two tags are equal. The promise is unforgeability: even after seeing the tags of many messages of their own choosing, someone without the key cannot make a valid tag for any message that was not tagged.
The construction most people write first is SHA-256(key ∥ message). It fails because of
length extension: Continuing a hash from a known digest without knowing the message, possible with SHA-256 and SHA-512 because their digest is the full internal state. It is why SHA-256(secret ∥ message) is not a MAC. More in “Hash functions: SHA-2, SHA-3 and BLAKE2”: a SHA-256 digest is the hash’s whole internal state after the
last block, so anyone who has seen one tag can go on hashing from that state and get a valid tag for the message with
SHA-256’s padding and extra bytes appended, all without the key. The table compares the usual candidates:
| Construction | Who can make a valid tag | What goes wrong |
|---|---|---|
SHA-256(message) |
anyone | there is no key, so a changed message gets a new digest |
SHA-256(key ∥ message) |
anyone who has seen one tag | length extension: the tag is SHA-256’s state, and hashing can continue from it |
| HMAC-SHA-256 | only holders of the key | no practical attack is known; it is the standard of RFC 2104 and FIPS 198-1 |
| KMAC256 | only holders of the key | also sound, built on SHA-3, which has no length extension; fewer platforms offer it |
HMAC: The MAC of RFC 2104 and FIPS 198-1, built from a hash H as H((K ⊕ opad) ∥ H((K ⊕ ipad) ∥ message)), with the key padded to one block, ipad the byte 0x36 and opad the byte 0x5c. HMAC-SHA-256 gives a 32-byte tag. is the answer this lesson builds. KMAC, defined by NIST in SP 800-185, is the SHA-3 based alternative; Node.js 24 offers it in Web Crypto as an experimental algorithm, and most protocols you will integrate with, webhooks among them, use HMAC.
Checkpoint
Check: Why a MAC needs a key
HMAC hashes twice, with the key mixed in before each hash
RFC 2104 defines HMAC for any hash H that reads its input in blocks of B bytes, 64 for SHA-256:
In words: K′ is the key padded with zero bytes to one block, after a key longer than a block has been replaced by its
hash; ipad is the byte 0x36 repeated over the block and opad the byte 0x5c. The inner hash mixes the key with the
message, and the outer hash mixes the key again with the inner result, so the tag is never the state of a hash that
an outsider could continue. For the key Jefe, the first four bytes 4a 65 66 65 become 7c 53 50 53 after the XOR
with ipad. Step through the whole computation for RFC 4231’s test case 2:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The key. HMAC takes a key and a message. Here the key is the 4 ASCII bytes of Jefe and the message is "what do ya want for nothing?", 28 bytes, as in RFC 4231 test case 2.
- One block of key. SHA-256 reads 64-byte blocks, so the key is padded with zero bytes to 64. A key longer than 64 bytes would first be replaced by its 32-byte SHA-256, as test case 6 does with its 131-byte key.
- The inner key. Every byte of the padded key is XORed with 0x36, the ipad byte. 4a becomes 7c, 65 becomes 53, and each zero byte becomes 36.
- Inner hash. The 64 bytes of inner key and the 28 message bytes are hashed together. The 32-byte result starts a2e48586; it is never sent, because its state could be extended.
- The outer key. The same padded key is XORed with 0x5c, the opad byte, giving 16393a39 followed by 60 bytes of 5c.
- Outer hash, the tag. The outer key and the inner hash are hashed again. The result, 5bdcc146 bf60754e …, is exactly the tag RFC 4231 publishes for test case 2, and a key holder is the only one who can compute it.
The program does the same with hashlib.sha256 alone. It prints the values of the steps above, then checks four of
RFC 4231’s test cases against the RFC and against Python’s own hmac module:
"""HMAC-SHA-256 built from hashlib.sha256 exactly as RFC 2104 defines it, checked against four test cases of RFC 4231
and against Python's own hmac module."""
import hashlib
import hmac
BLOCK = 64 # SHA-256 reads its input in blocks of 64 bytes
def hmac_sha256(key: bytes, message: bytes) -> bytes:
if len(key) > BLOCK:
key = hashlib.sha256(key).digest() # a key longer than one block is hashed first
key = key.ljust(BLOCK, b"\x00") # then padded with zero bytes to one block
inner = hashlib.sha256(bytes(b ^ 0x36 for b in key) + message).digest()
return hashlib.sha256(bytes(b ^ 0x5C for b in key) + inner).digest()
# The steps for test case 2: the key "Jefe" (4 bytes) and a 28-byte message.
key, message = b"Jefe", b"what do ya want for nothing?"
padded = key.ljust(BLOCK, b"\x00")
inner = hashlib.sha256(bytes(b ^ 0x36 for b in padded) + message).digest()
print(f"key XOR ipad starts {bytes(b ^ 0x36 for b in padded)[:6].hex()}, key XOR opad starts {bytes(b ^ 0x5C for b in padded)[:6].hex()}")
print(f"inner hash {inner.hex()}")
print(f"outer hash {hmac_sha256(key, message).hex()}")
print()
CASES = [ # RFC 4231 section 4: test cases 1, 2, 5 (the tag cut to 128 bits) and 6 (a key of 131 bytes)
("1", bytes([0x0B] * 20), b"Hi There", "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"),
("2", b"Jefe", b"what do ya want for nothing?", "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"),
("5", bytes([0x0C] * 20), b"Test With Truncation", "a3b6167473100ee06e0c796c2955552b"),
("6", bytes([0xAA] * 131), b"Test Using Larger Than Block-Size Key - Hash Key First", "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"),
]
for name, key, message, expected in CASES:
tag = hmac_sha256(key, message).hex()[: len(expected)]
library = hmac.new(key, message, "sha256").hexdigest()[: len(expected)]
print(f"test case {name}: {len(key):3}-byte key, {len(expected) * 4} bits, as RFC 4231: {tag == expected}, as hmac.new: {tag == library}")
naive = hashlib.sha256(b"Jefe" + b"what do ya want for nothing?").hexdigest()
print(f"\nsha256(key + message) for test case 2: {naive[:16]}…, a different value and not a MAC") Output
key XOR ipad starts 7c5350533636, key XOR opad starts 16393a395c5c inner hash a2e485863d27f9d864ac8d802432a1ed477d8c4c6f349d16d4e7e917c629cad7 outer hash 5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843 test case 1: 20-byte key, 256 bits, as RFC 4231: True, as hmac.new: True test case 2: 4-byte key, 256 bits, as RFC 4231: True, as hmac.new: True test case 5: 20-byte key, 128 bits, as RFC 4231: True, as hmac.new: True test case 6: 131-byte key, 256 bits, as RFC 4231: True, as hmac.new: True sha256(key + message) for test case 2: 689a1a0fa183259e…, a different value and not a MAC
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hmac_by_hand.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
All four match, including test case 6, whose 131-byte key is longer than a block and is hashed first, and test case 5,
whose tag is cut to 128 bits. The last line shows SHA-256(key ∥ message) for the same inputs: a different value,
and not a MAC. RFC 4231 gives seven such test vector: A published input with its exact expected output, such as the HMAC test cases of RFC 4231 or NIST's example values, used to show that code computes the standard function and not something close to it. for each of SHA-224, SHA-256,
SHA-384 and SHA-512, chosen to reach the edges where implementations slip: a key shorter than the output, keys longer
than a block and a truncated tag. NIST standardises the same construction in FIPS 198-1 and plans to move it into SP
800-224, which is still a draft.
Two rules of RFC 2104 set the sizes. The key should be random and at least as long as the hash output, 32 bytes for SHA-256; shorter keys are strongly discouraged. A tag may be truncated to fit a header or a URL, but to no fewer bits than half the hash output and never below 80, which makes 128 bits the shortest HMAC-SHA-256 tag. Each bit you drop doubles a forger’s luck: a random guess at a t-bit tag is right once in 2ᵗ tries, once in about 4.3 billion for 32 bits but once in about 3.4 × 10³⁸ for 128.
Make HMAC keys with a CSPRNG
secrets.token_bytes(32) in Python or crypto.getRandomValues(new Uint8Array(32)) in JavaScript gives a key of
the right size. A key typed by a person, or derived from a password with a fast hash, is guessable however long it
looks.
Checkpoint
Check: How HMAC hashes twice
The same tag from Web Crypto, node:crypto and Python
Every platform has HMAC built in, and all of them must give the published bytes. This program signs and verifies
RFC 4231’s test case 2 and GitHub’s test values with Web Crypto, computes them again with createHmac from
node:crypto, and prints the header that GitHub’s documentation shows:
// HMAC-SHA-256 in Node.js 24 two ways, Web Crypto's sign and verify and node:crypto's createHmac, on RFC 4231 test
// case 2 and on the test values GitHub documents for its webhook signatures.
import { createHmac } from 'node:crypto';
const enc = new TextEncoder();
const hex = (bytes) => Buffer.from(bytes).toString('hex');
const CASES = [
['RFC 4231 case 2', 'Jefe', 'what do ya want for nothing?', '5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843'],
['GitHub test values', "It's a Secret to Everybody", 'Hello, World!', '757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17'],
];
for (const [name, secret, message, expected] of CASES) {
const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
const webTag = hex(await crypto.subtle.sign('HMAC', key, enc.encode(message)));
const nodeTag = createHmac('sha256', secret).update(message).digest('hex');
console.log(`${name}: ${webTag}`);
console.log(` Web Crypto and createHmac agree: ${webTag === nodeTag}, as published: ${webTag === expected}`);
const tag = Buffer.from(expected, 'hex');
const ok = await crypto.subtle.verify('HMAC', key, tag, enc.encode(message));
const changed = await crypto.subtle.verify('HMAC', key, tag, enc.encode(`${message} `));
console.log(` subtle.verify: ${ok}; with one space added to the message: ${changed}`);
}
const [, secret, body] = CASES[1];
console.log(`\nX-Hub-Signature-256: sha256=${createHmac('sha256', secret).update(body).digest('hex')}`); Output
RFC 4231 case 2: 5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843 Web Crypto and createHmac agree: true, as published: true subtle.verify: true; with one space added to the message: false GitHub test values: 757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17 Web Crypto and createHmac agree: true, as published: true subtle.verify: true; with one space added to the message: false X-Hub-Signature-256: sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node hmac.mjs
Both tags match the published values, and subtle.verify refuses the message with one space added. The two APIs work
differently. Web Crypto turns the secret into a key object with importKey, naming the HMAC algorithm and its hash,
and then sign returns the tag while verify recomputes it and compares for you. createHmac is a hash object with
a key: update with the data, then digest. GitHub sends sha256= and the tag as 64 hex digits in its
X-Hub-Signature-256 header, keeps an X-Hub-Signature header with HMAC-SHA-1 for legacy reasons only, and tells
receivers never to compare signatures with a plain ==.
Not affiliated
Based on public information about GitHub. MySmartCoPilot is not affiliated with GitHub, and GitHub’s real webhook format may differ from what this lesson describes.
Python gives the same bytes. Run the program, then change the body or the secret and watch the tags change:
"""The same two HMAC-SHA-256 tags with Python's hmac module: hmac.new, the one-call hmac.digest and compare_digest,
then what becomes of SHAKE, which has no fixed digest size and so cannot be used for HMAC."""
import hmac
CASES = [
("RFC 4231 case 2", b"Jefe", b"what do ya want for nothing?", "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"),
("GitHub test values", b"It's a Secret to Everybody", b"Hello, World!", "757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17"),
]
for name, secret, message, expected in CASES:
tag = hmac.new(secret, message, "sha256").hexdigest()
one_call = hmac.digest(secret, message, "sha256")
print(f"{name}: {tag}")
print(f" hmac.new and hmac.digest agree: {one_call.hex() == tag}, as published: {hmac.compare_digest(tag, expected)}")
changed = hmac.digest(secret, message + b" ", "sha256")
print(f" compare_digest with one space added to the message: {hmac.compare_digest(changed, bytes.fromhex(expected))}")
secret, body = CASES[1][1], CASES[1][2]
print(f"\nX-Hub-Signature-256: sha256={hmac.new(secret, body, 'sha256').hexdigest()}")
try:
shake = hmac.new(secret, body, "shake_128")
except ValueError as e:
print(f"shake_128: refused when the HMAC object is made ({type(e).__name__})")
else:
try:
shake.digest()
except TypeError as e:
print(f"shake_128: accepted when made, refused at digest() ({type(e).__name__})") Output
RFC 4231 case 2: 5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843 hmac.new and hmac.digest agree: True, as published: True compare_digest with one space added to the message: False GitHub test values: 757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17 hmac.new and hmac.digest agree: True, as published: True compare_digest with one space added to the message: False X-Hub-Signature-256: sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17 shake_128: refused when the HMAC object is made (ValueError)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hmac_api.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
hmac.new needs the name of the hash, which has been required since Python 3.8, and hmac.digest computes a tag in
one call. Any hash with a fixed digest size will do. Extendable-output functions such as SHAKE128 will not: Python’s
documentation rules them out, and the last line shows CPython 3.14.8 refusing one with ValueError as soon as the
HMAC object is made. Not every build refuses it that early, so code that takes a hash name from its input should
refuse extendable-output functions itself, as the lab’s first step does.
HMAC without OpenSSL in Python 3.14
Python 3.14 added a built-in HMAC made from formally verified code of the HACL* project, used when OpenSSL’s HMAC is
not available. The page’s browser runner, Pyodide 314.0.7, has no OpenSSL, so its hmac is that built-in one: the
tags are the same, but it accepts shake_128 when the object is made and refuses it only at digest(), with a
TypeError.
In your browser, Pyodide 314.0.7 (CPython 3.14.2) prints:
RFC 4231 case 2: 5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843 hmac.new and hmac.digest agree: True, as published: True compare_digest with one space added to the message: False GitHub test values: 757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17 hmac.new and hmac.digest agree: True, as published: True compare_digest with one space added to the message: False X-Hub-Signature-256: sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17 shake_128: accepted when made, refused at digest() (TypeError)
Checkpoint
Check: HMAC in Web Crypto, Node.js and Python
A check that stops at the first wrong byte leaks how much was right
Once the tag is computed, the receiver compares it with the one it received, and the natural way to compare is the
wrong one. A loop that returns at the first differing byte, like the == of most languages, does work that grows
with the number of leading bytes that are right. Step through such a loop on a 4-byte tag and a guess whose first two
bytes are right, then through a loop that reads every pair:
Program · trace/compare_trace.py
# A 4-byte tag as hex pairs, and a guess
# whose first 2 bytes are right.
expected = ["5b", "dc", "c1", "46"]
received = ["5b", "dc", "00", "00"]
read = 0
equal = True
for e, r in zip(expected, received):
read += 1
if e != r:
equal = False
break
print("early exit:", equal, "after", read)
read = 0
diff = 0
for e, r in zip(expected, received):
read += 1
diff |= int(e, 16) ^ int(r, 16)
print("every pair:", diff == 0, "after", read) Variables
- just taken by the loop
- changed in this step
- passed by the loop
- read next
- where the loop stopped
Printed so far
Nothing printed yetearly exit: False after 3 every pair: False after 4
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 33 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 3.
- Line 3.
expected = ["5b", "dc", "c1", "46"]makesexpecteda list of 4 items. - Line 4.
received = ["5b", "dc", "00", "00"]makesreceiveda list of 4 items. - Line 6.
read = 0setsreadto0. - Line 7.
equal = TruesetsequaltoTrue. - Line 8.
for e, r in zip(expected, received):takes the items at position 0 ofexpectedandreceivedtogether:eis'5b'andris'5b'. - Line 9.
read += 1changesreadfrom0to1. - Line 10.
e != ris false (eis'5b'andris'5b'), so Python skips the block underif e != r:. - Line 8.
for e, r in zip(expected, received):takes the items at position 1 ofexpectedandreceivedtogether:eis'dc'andris'dc'. - Line 9.
read += 1changesreadfrom1to2. - Line 10.
e != ris false (eis'dc'andris'dc'), so Python skips the block underif e != r:. - Line 8.
for e, r in zip(expected, received):takes the items at position 2 ofexpectedandreceivedtogether:eis'c1'andris'00'. - Line 9.
read += 1changesreadfrom2to3. - Line 10.
e != ris true (eis'c1'andris'00'), so the block underif e != r:runs. - Line 11.
equal = FalsesetsequaltoFalse(it wasTrue). - Line 12.
breakleaves the loop at once, without reading the items it has not reached. The bug The third pair differs, so the first loop stops after reading 3 pairs. A guess with 3 right bytes would have made it read 4, so the time it takes tells how much of the guess is right. - Line 13.
print("early exit:", equal, "after", read)printsearly exit: False after 3.readis 3: the work of the early exit depended on the guess. - Line 15.
read = 0setsreadto0(it was3). - Line 16.
diff = 0setsdiffto0. - Line 17.
for e, r in zip(expected, received):takes the items at position 0 ofexpectedandreceivedtogether:eis'5b'andris'5b'. - Line 18.
read += 1changesreadfrom0to1. - Line 19.
diff |= int(e, 16) ^ int(r, 16)diffkeeps its value,0. - Line 17.
for e, r in zip(expected, received):takes the items at position 1 ofexpectedandreceivedtogether:eis'dc'andris'dc'. - Line 18.
read += 1changesreadfrom1to2. - Line 19.
diff |= int(e, 16) ^ int(r, 16)diffkeeps its value,0. - Line 17.
for e, r in zip(expected, received):takes the items at position 2 ofexpectedandreceivedtogether:eis'c1'andris'00'. - Line 18.
read += 1changesreadfrom2to3. - Line 19.
diff |= int(e, 16) ^ int(r, 16)changesdifffrom0to193. - Line 17.
for e, r in zip(expected, received):takes the items at position 3 ofexpectedandreceivedtogether:eis'46'andris'00'. - Line 18.
read += 1changesreadfrom3to4. - Line 19.
diff |= int(e, 16) ^ int(r, 16)changesdifffrom193to199. The fix The second loop has read all 4 pairs and only remembers whether any of them differed. It does the same work for every guess of this length. - Line 17.
for e, r in zip(expected, received):stops:expectedandreceivedhave no item at position 4, so zip makes no more pairs and the loop ends. - Line 20.
print("every pair:", diff == 0, "after", read)printsevery pair: False after 4. That was the last line, so the program ends. Both loops reject the guess. Only the first one also told how many leading bytes were right, which a constant-time comparison must never do.
The first loop stopped after 3 of the 4 pairs, the second read all 4. For a real 32-byte tag each byte is a few nanoseconds, but a difference that repeats on every request can be measured by averaging many requests, so the time a check takes must not depend on the guess. That is a timing side channel: Information that leaks through how long an operation takes, such as a tag check that answers sooner when fewer leading bytes are right. Averaging many measurements can expose differences of nanoseconds., and ASVS 5.0 requirement 11.2.4 asks, at its highest level, for comparisons with no short-circuit at all. This program counts the byte comparisons of both checks for wrong guesses whose first 0, 8, 16 and 31 bytes are right. Its last line is a test a suite can run on any comparison: does the work depend on the guess?
"""How many byte comparisons a tag check makes before it answers, for the right tag and for wrong tags whose first 0, 8,
16 and 31 bytes are right. A check that stops early does work that depends on the guess; the fix does not."""
import hmac
KEY = b"lab-only-key-not-a-real-secret-0" # 32 bytes, made up for this lab
BODY = b'{"order": 1042, "status": "paid"}'
expected = hmac.digest(KEY, BODY, "sha256")
def early_exit_equal(a: bytes, b: bytes) -> tuple[bool, int]:
steps = 0
if len(a) != len(b):
return False, steps
for x, y in zip(a, b):
steps += 1
if x != y:
return False, steps # stops at the first wrong byte
return True, steps
def every_byte_equal(a: bytes, b: bytes) -> tuple[bool, int]:
steps = 0
if len(a) != len(b):
return False, steps
diff = 0
for x, y in zip(a, b):
steps += 1
diff |= x ^ y # remembers any difference and reads on
return diff == 0, steps
def right_for(k: int) -> bytes:
"""The right tag with byte number k changed, so that exactly its first k bytes are right."""
guess = bytearray(expected)
guess[k] ^= 0x01
return bytes(guess)
print(f"{'guess':<22} {'early exit':<16} {'every byte':<16} compare_digest")
for label, guess in [("the right tag", expected)] + [(f"first {k} bytes right", right_for(k)) for k in (0, 8, 16, 31)]:
ok1, steps1 = early_exit_equal(expected, guess)
ok2, steps2 = every_byte_equal(expected, guess)
print(f"{label:<22} {ok1!s:<5} {steps1:>2} steps {ok2!s:<5} {steps2:>2} steps {hmac.compare_digest(expected, guess)}")
# The check a test suite can run: does the work depend on how much of a wrong guess is right?
early = {early_exit_equal(expected, right_for(k))[1] for k in range(32)}
fixed = {every_byte_equal(expected, right_for(k))[1] for k in range(32)}
print(f"\nwork depends on the guess: early exit {len(early) > 1} ({len(early)} different counts), every byte {len(fixed) > 1}") Output
guess early exit every byte compare_digest the right tag True 32 steps True 32 steps True first 0 bytes right False 1 steps False 32 steps False first 8 bytes right False 9 steps False 32 steps False first 16 bytes right False 17 steps False 32 steps False first 31 bytes right False 32 steps False 32 steps False work depends on the guess: early exit True (32 different counts), every byte False
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 compare_leak.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The early exit made 1, 9, 17 and 32 comparisons, so its work tells how many leading bytes were right, and over the 32 possible positions it made 32 different counts. The full check made 32 every time. The test fails on the weak check and passes on the fixed one.
Comparing the header as text
Wrong: if header == "sha256=" + expected_hex, which stops at the first different character and treats an
upper-case tag as wrong. Right: check the prefix and the length, decode the hex strictly, and compare the 32 bytes
with hmac.compare_digest or crypto.timingSafeEqual.
Use the platform’s constant-time comparison: A comparison of secret values whose running time does not depend on where they differ, because it reads every byte instead of stopping at the first difference. Python's hmac.compare_digest and Node's crypto.timingSafeEqual provide one. rather than a loop of your
own. Python’s hmac.compare_digest and Node’s
crypto.timingSafeEqual are written not to short-circuit on content, and the Python loops on this page exist only to
count; the interpreter does not promise that a loop takes the same time for every input. The length of a tag is not
secret, so checking it first leaks nothing, and Node.js insists on it: timingSafeEqual throws for inputs of
different lengths. Decoding needs the same care, because Buffer.from(text, 'hex') silently stops at the first
character that is not a hex digit. The program shows both and wraps them in a check that returns false for every
malformed tag:
// Comparing HMAC tags in Node.js 24: crypto.timingSafeEqual, the error it throws for inputs of different lengths,
// Buffer's lenient hex decoding, and a wrapper that turns every malformed tag into a plain false.
import { createHmac, timingSafeEqual } from 'node:crypto';
const secret = "It's a Secret to Everybody"; // GitHub's documented test secret
const expected = createHmac('sha256', secret).update('Hello, World!').digest(); // 32 bytes
try {
timingSafeEqual(expected, Buffer.from('757107ea', 'hex'));
} catch (e) {
console.log(`timingSafeEqual on 32 and 4 bytes throws ${e.name} ${e.code}`);
}
console.log(`Buffer.from('757107zz', 'hex') keeps ${Buffer.from('757107zz', 'hex').length} bytes and drops the rest without an error`);
/** A received tag in hex against the expected bytes: malformed or wrong-length input is false, never an exception. */
function tagMatches(expectedBytes, receivedHex) {
if (typeof receivedHex !== 'string' || receivedHex.length !== expectedBytes.length * 2) return false;
if (!/^[0-9a-f]+$/i.test(receivedHex)) return false;
return timingSafeEqual(expectedBytes, Buffer.from(receivedHex, 'hex'));
}
const header = 'sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17';
const received = header.startsWith('sha256=') ? header.slice('sha256='.length) : '';
const tries = [
['the published signature', received],
['last hex digit changed', `${received.slice(0, -1)}8`],
['cut to 16 hex digits', received.slice(0, 16)],
['64 characters, not hex', 'zz'.repeat(32)],
['the same in capitals', received.toUpperCase()],
];
console.log();
for (const [label, hex] of tries) console.log(`${label.padEnd(25)} ${tagMatches(expected, hex)}`); Output
timingSafeEqual on 32 and 4 bytes throws RangeError ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH
Buffer.from('757107zz', 'hex') keeps 3 bytes and drops the rest without an error
the published signature true
last hex digit changed false
cut to 16 hex digits false
64 characters, not hex false
the same in capitals true
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node compare.mjs
The wrapper accepts the published tag in either case and rejects the changed, short and non-hex ones without an
error. Python’s compare_digest returns False for inputs of different lengths instead of raising, and its
documentation notes that a timing difference could then reveal their lengths, never their values. Web Crypto’s
subtle.verify compares inside the platform, so no comparison of yours is involved, and Node’s documentation adds
that timingSafeEqual does not make the code around it timing-safe.
Key idea
Recompute the tag over the exact bytes received, check the received tag’s format and length, and compare with the platform’s constant-time function, so that a wrong tag costs the same however much of it is right.
Checkpoint
Check: Checking a tag in constant time
Practise: HMAC for any hash, and a header check that cannot leak
The lab has two Python steps, each with sample tests you can run here. Step one takes the construction beyond
SHA-256 to SHA-384, SHA-512 and SHA-3, whose blocks are not 64 bytes, and holds it to RFC 4231. Step two checks a
sha256= header the way a webhook receiver must, strictly, without raising and in constant time, on GitHub’s test
values:
Code lab · 2 steps
Build HMAC and a header check you can trust
Write HMAC for any fixed-size hash from its definition, then a signature-header check that rejects every malformed header and compares in constant time.
- HMAC for any fixed-size hash, block size included To do
- Check a signature header without raising or leaking To do
Exercise · Medium · Python
HMAC for any fixed-size hash, block size included
Write hmac_digest(name, key, message) in hmac_any.py. It returns the HMAC of message under key as RFC 2104 defines it, for any fixed-size hash that hashlib.new(name) makes, such as sha256, sha384, sha512 or sha3_256, using only hashlib:
- A key longer than the hash's block is hashed first; then it is padded with zero bytes to one block. The block is 64 bytes for SHA-256 but 128 bytes for SHA-384 and SHA-512: read it from the hash object's
block_size. - The result is
H((K ⊕ opad) ∥ H((K ⊕ ipad) ∥ message)), with ipad the byte0x36and opad the byte0x5crepeated over the block. - Extendable-output functions (
shake_128,shake_256) have no fixed digest size, so raiseValueErrorfor them.
The sample tests hold your function to RFC 4231's values for SHA-256, SHA-384 and SHA-512 and compare it with Python's own hmac module for keys just shorter than, equal to and just longer than a block. The starter code is the lesson's SHA-256 recipe with its 64-byte block written in, so it is right for SHA-256 only.
Starter code · hmac_any.py
import hashlib
BLOCK = 64 # the block of SHA-256, written in: wrong for SHA-384 and SHA-512
def hmac_digest(name: str, key: bytes, message: bytes) -> bytes:
"""The HMAC of message under key with the hash hashlib.new(name), as RFC 2104 defines it."""
if len(key) > BLOCK:
key = hashlib.new(name, key).digest()
key = key.ljust(BLOCK, b"\x00")
inner = hashlib.new(name, bytes(b ^ 0x36 for b in key) + message).digest()
return hashlib.new(name, bytes(b ^ 0x5C for b in key) + inner).digest() The sample tests · test_hmac_any.py
import hmac
from hmac_any import hmac_digest
JEFE, WANT = b"Jefe", b"what do ya want for nothing?" # RFC 4231, test case 2
LONG_KEY = bytes([0xAA] * 131) # RFC 4231, test case 6
LONG_MSG = b"Test Using Larger Than Block-Size Key - Hash Key First"
def raises_value_error(call):
try:
call()
except ValueError:
return True
return False
def test_rfc_4231_case_2_three_hashes():
"""RFC 4231 test case 2 for SHA-256, SHA-384 and SHA-512"""
assert hmac_digest("sha256", JEFE, WANT).hex() == "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
assert hmac_digest("sha384", JEFE, WANT).hex() == (
"af45d2e376484031617f78d2b58a6b1b9c7ef464f5a01b47e42ec3736322445e8e2240ca5e69e2c78b3239ecfab21649"
)
assert hmac_digest("sha512", JEFE, WANT).hex() == (
"164b7a7bfcf819e2e395fbe73b56e0a387bd64222e831fd610270cd7ea250554"
"9758bf75c05a994a6d034f65f8f0e6fdcaeab1a34d4a6b4b636e070a38bce737"
)
def test_rfc_4231_case_6_key_longer_than_a_block():
"""a 131-byte key is hashed first, for SHA-256 (64-byte block) and SHA-512 (128-byte block)"""
assert hmac_digest("sha256", LONG_KEY, LONG_MSG).hex() == "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
assert hmac_digest("sha512", LONG_KEY, LONG_MSG).hex() == (
"80b24263c7c1a3ebb71493c1dd7be8b49b46d1f41b4aeec1121b013783f8f352"
"6b56d037e05f2598bd0fd2215d6a1e5295e64f73f63f0aec8b915a985d786598"
)
def test_keys_at_the_block_edges():
"""keys of 0, 63, 64, 65, 127, 128 and 129 bytes give what the hmac module gives"""
for name in ["sha256", "sha384", "sha512", "sha3_256"]:
for size in [0, 63, 64, 65, 127, 128, 129]:
key = bytes(range(size))
assert hmac_digest(name, key, b"edge") == hmac.new(key, b"edge", name).digest(), (name, size)
def test_digest_sizes():
"""the tag is as long as the hash's digest: 32, 48 and 64 bytes"""
assert [len(hmac_digest(n, b"k", b"m")) for n in ["sha256", "sha384", "sha512"]] == [32, 48, 64]
def test_extendable_output_refused():
"""SHAKE has no fixed digest size, so it is refused with ValueError"""
for name in ["shake_128", "shake_256"]:
assert raises_value_error(lambda: hmac_digest(name, b"k", b"m")), name A hint
Make the hash object first, h = hashlib.new(name), and take block = h.block_size. Use hashlib.new(name, data) for the three hashes the construction needs. An extendable-output hash is the one whose name starts with shake.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Easy · Python
Check a signature header without raising or leaking
Write verify_header(secret, body, header) in signature.py. A sender puts sha256= and the HMAC-SHA-256 of the raw body, as 64 hex digits, in a header; your function says whether header is exactly that for body under secret:
- Return
Trueonly forsha256=followed by exactly 64 hex digits, in lower or upper case, that encode the right tag. Anything else returnsFalse: a missing or different prefix, a tag of the wrong length, characters that are not hex digits (spaces included), or a tag for other bytes. - Never raise an exception for a bad header: a sender, or anyone pretending to be one, controls it.
- Compare the tag bytes with
hmac.compare_digest, never with==.
The sample tests use the test values GitHub documents for its webhooks (the secret It's a Secret to Everybody and the body Hello, World!). The starter code decodes whatever follows the prefix with bytes.fromhex, which raises on bad input and also skips spaces, and compares with ==.
Starter code · signature.py
import hmac
def verify_header(secret: bytes, body: bytes, header: str) -> bool:
"""True when header is "sha256=" and the hex HMAC-SHA-256 of body under secret."""
received = bytes.fromhex(header.removeprefix("sha256=")) # raises on bad hex, skips spaces
return received == hmac.digest(secret, body, "sha256") # == stops at the first difference The sample tests · test_signature.py
import hmac
from signature import verify_header
SECRET = b"It's a Secret to Everybody" # GitHub's documented test values
BODY = b"Hello, World!"
TAG = "757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17"
def never_raises(header):
try:
return verify_header(SECRET, BODY, header)
except Exception as e: # noqa: BLE001 - a bad header must never raise
return f"raised {type(e).__name__}"
def test_published_header():
"""the header GitHub documents for its test values is accepted"""
assert verify_header(SECRET, BODY, f"sha256={TAG}") is True
def test_capital_hex_digits():
"""hex digits in upper case encode the same tag"""
assert verify_header(SECRET, BODY, f"sha256={TAG.upper()}") is True
def test_other_bytes_rejected():
"""a body with one byte changed, and the right tag under another secret, are rejected"""
assert verify_header(SECRET, b"Hello, World?", f"sha256={TAG}") is False
other = hmac.new(b"another secret", BODY, "sha256").hexdigest()
assert verify_header(SECRET, BODY, f"sha256={other}") is False
def test_prefix_required():
"""a bare hex tag, a sha1= header and an empty header are rejected"""
sha1_tag = hmac.new(SECRET, BODY, "sha1").hexdigest()
for header in [TAG, f"sha1={sha1_tag}", f"SHA256={TAG}", ""]:
assert never_raises(header) is False, header
def test_bad_tags_rejected_without_raising():
"""wrong lengths, letters that are not hex and spaces give False, never an exception"""
for tag in [TAG[:-1], TAG + "0", TAG[:32], "zz" * 32, TAG[:2] + " " + TAG[3:], " " + TAG[1:], "é" * 64]:
assert never_raises(f"sha256={tag}") is False, tag A hint
Check the shape before you decode anything: the prefix with str.startswith, the length of what follows, and every character against "0123456789abcdefABCDEF". Only then call bytes.fromhex, and compare its result with hmac.digest(secret, body, "sha256") using hmac.compare_digest.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
The self-check below covers the four ideas. It starts with the idea you know least, asks a harder question after each right answer and brings back the questions you miss:
Practice that adapts to you
Practise this lesson
13 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to mid level): how does a MAC differ from a hash, and why is HMAC used instead of hashing the key together with the message?
A hash such as SHA-256 has no key, so anyone can compute the digest of any message: it shows that bytes are unchanged only when the digest arrives over a channel an attacker cannot touch. A MAC takes a secret key as well, so only key holders can make a valid tag, and a receiver with the key can tell that the message is unchanged and came from one of them. Hashing the key and the message together, as SHA-256(key ∥ message), is not a safe MAC because SHA-256’s digest is its internal state, which allows length extension: from one tag, anyone can make a valid tag for a longer message. HMAC hashes twice, with the key mixed in before each hash, which closes that gap; it is standardised in RFC 2104 and FIPS 198-1 and is checked against RFC 4231’s test vectors.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why does a message tag need a key, and what does a MAC promise?
A hash has no key, so anyone who can change a body can compute the SHA-256 of the new one. A MAC is a function of a secret key and the message whose tag nobody can compute without the key; the receiver recomputes it over the bytes it received and accepts only an equal tag. The promise is unforgeability: after seeing tags of many chosen messages you still cannot tag another.
-
Which constructions give a valid MAC: SHA-256(message), SHA-256(key ∥ message), HMAC-SHA-256, KMAC256?
SHA-256(message) has no key, so anyone can make it. SHA-256(key ∥ message) falls to length extension. HMAC-SHA-256 (RFC 2104, FIPS 198-1) has no practical attack known. KMAC256 (NIST SP 800-185) is also sound because SHA-3 has no length extension, but fewer platforms offer it; most webhooks use HMAC.
-
How does HMAC compute its tag?
HMAC(K, m) = H((K′ ⊕ opad) ∥ H((K′ ⊕ ipad) ∥ m)). K′ is the key padded with zero bytes to one block (64 bytes for SHA-256), after a key longer than a block is replaced by its hash; ipad is 0x36 repeated and opad 0x5c repeated. The tag is never the state of a hash that an outsider could continue.
-
What key and tag sizes does RFC 2104 set for HMAC-SHA-256, and how do you make the key?
The key should be random and at least as long as the hash output, 32 bytes; shorter keys are strongly discouraged. Make it with a CSPRNG: secrets.token_bytes(32) or crypto.getRandomValues(new Uint8Array(32)). A tag may be truncated to no fewer bits than half the hash output and never below 80, so 128 bits is the shortest. A t-bit tag is guessed once in 2^t tries.
-
Why test HMAC against the vectors of RFC 4231?
It gives seven test cases for each of SHA-224, SHA-256, SHA-384 and SHA-512, chosen for the edges where implementations slip: a key shorter than the output, keys longer than a block (which are hashed first) and a truncated tag. Web Crypto, createHmac and Python's hmac gave identical bytes on them and on GitHub's published webhook value.
-
How do Web Crypto, createHmac and Python's hmac compute and verify a tag?
Web Crypto turns the secret into a key with importKey (naming HMAC and its hash); sign returns the tag and verify recomputes and compares for you. In Node, createHmac(hash, key) takes update(data) then digest(). Python's hmac.new needs the hash name (required since 3.8) and hmac.digest does it in one call. Extendable-output functions such as SHAKE128 will not work.
-
Why does an early-exit comparison of tags leak, and what do you use instead?
A loop that returns at the first differing byte, like ==, does work that grows with the number of leading bytes that are right, and averaging many requests makes the difference measurable (ASVS 5.0, 11.2.4). The early exit made 1, 9, 17 and 32 comparisons for guesses with 0, 8, 16 and 31 right bytes; the full check made 32 every time. Use hmac.compare_digest, crypto.timingSafeEqual or subtle.verify.
-
How should a receiver check a sha256= signature header?
Check the prefix and the length, decode the hex strictly and compare the 32 bytes in constant time; never compare the header as text. Buffer.from(text, 'hex') silently stops at the first non-hex character, timingSafeEqual throws for inputs of different lengths (a tag's length is not secret, so check it first), and compare_digest returns False. Malformed tags must give false, not an error.
Key takeaways
- A MAC is a keyed function: only key holders can make a valid tag, so a digest without a key, or SHA-256 with the key in front, which length extension breaks, does not authenticate a message.
- HMAC-SHA-256 pads a random key of at least 32 bytes to one 64-byte block, hashes it with ipad and the message, then again with opad; four RFC 4231 test cases, a long key and a truncated tag among them, matched the code built from SHA-256 alone.
- Web Crypto,
createHmacand Python’shmacgave the same bytes for RFC 4231 and GitHub’s published test values, and every verifier refused a message with one space added. - A comparison that stops at the first wrong byte made 32 different amounts of work for 32 wrong guesses; check the
format and length, then compare with
hmac.compare_digest,crypto.timingSafeEqualorsubtle.verify.
Terms in this lesson
- authentication tag
- The short value a MAC or an authenticated cipher computes over a message, such as the 32 bytes of HMAC-SHA-256. The receiver accepts the message only if the tag it recomputes equals the one it received.
- constant-time comparison
- A comparison of secret values whose running time does not depend on where they differ, because it reads every byte instead of stopping at the first difference. Python's hmac.compare_digest and Node's crypto.timingSafeEqual provide one.
- HMAC
- The MAC of RFC 2104 and FIPS 198-1, built from a hash H as H((K ⊕ opad) ∥ H((K ⊕ ipad) ∥ message)), with the key padded to one block, ipad the byte 0x36 and opad the byte 0x5c. HMAC-SHA-256 gives a 32-byte tag.
- message authentication code (MAC)
- A function of a secret key and a message whose output, the tag, only holders of the key can compute. A receiver with the key recomputes the tag to check that the message is unchanged and came from a key holder; HMAC and KMAC are MACs.
- test vector
- A published input with its exact expected output, such as the HMAC test cases of RFC 4231 or NIST's example values, used to show that code computes the standard function and not something close to it.
- timing side channel
- Information that leaks through how long an operation takes, such as a tag check that answers sooner when fewer leading bytes are right. Averaging many measurements can expose differences of nanoseconds.
References
- RFC 2104: HMAC, Keyed-Hashing for Message Authentication (Internet Engineering Task Force (IETF))
- RFC 4231: Identifiers and Test Vectors for HMAC-SHA-224, HMAC-SHA-256, HMAC-SHA-384 and HMAC-SHA-512 (Internet Engineering Task Force (IETF))
- FIPS 198-1: The Keyed-Hash Message Authentication Code (HMAC) (National Institute of Standards and Technology (NIST))
- SP 800-224 (initial public draft): Keyed-Hash Message Authentication Code (HMAC) (National Institute of Standards and Technology (NIST))
- SP 800-185: SHA-3 Derived Functions, cSHAKE, KMAC, TupleHash and ParallelHash (National Institute of Standards and Technology (NIST))
- Validating webhook deliveries (GitHub)
- Python 3.14 documentation: hmac, keyed-hashing for message authentication (Python Software Foundation)
- What's New in Python 3.14: hmac (Python Software Foundation)
- Node.js 24 documentation: Crypto (createHmac and timingSafeEqual) (OpenJS Foundation (Node.js))
- Node.js 24 documentation: Web Crypto API (OpenJS Foundation (Node.js))
- OWASP Application Security Verification Standard 5.0, V11 Cryptography (requirement 11.2.4) (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress