Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 25 minutes
  • Examples run with Node.js 24.21.0, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free: 25 minutes

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

  1. Question 1 of 2 A sender adds the SHA-256 of each webhook body in a header, and the receiver accepts a body whose digest matches. Who can make the receiver accept a body they changed?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Anyone who can change the body on its way, or send a request of their own

    A digest shows that bytes are unchanged only when the expected digest comes from a channel the attacker cannot touch. A header in the same request is not such a channel, because a hash has no key. A MAC fixes this: its tag depends on a secret that only the sender and the receiver hold.

  2. Question 2 of 2 An API tags requests with SHA-256(secret ∥ body). Someone has captured one request and its tag, but not the secret. What can they do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Make a valid tag for the same body with SHA-256's padding and extra bytes of their choice appended

    SHA-256's digest is its internal state. From the tag of secret ∥ body, an attacker can go on hashing extra blocks and get the tag of secret ∥ body ∥ padding ∥ extra without knowing the secret. HMAC hashes the inner result again with the key, so its tag is never a state that can be continued.

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:

HMAC(K,m)=H((K′⊕︎opad)‖H((K′⊕︎ipad)‖m))\mathrm{HMAC}(K, m) = H\big((K' \oplus \mathrm{opad}) \,\|\, H((K' \oplus \mathrm{ipad}) \,\|\, m)\big)

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:

Interactive · stage by stage HMAC-SHA-256 of RFC 4231 test case 2, one step at a time Step through the six steps that turn the key Jefe and a 28-byte message into the published tag. Each box shows the bytes it produces.
HMAC-SHA-256 of RFC 4231 test case 2, one step at a time, step 6 of 6. Showing The key, Pad to one block, XOR with ipad, Inner hash (a2e48586 3d27f9d8 …), XOR with opad, Outer hash (5bdcc146 bf60754e …). In focus: Outer hash.over 64 bytes? hash itThe keyJefe = 4a 65 66 65Pad to one block4a656665, then 60 zero bytesXOR with ipad7c535053 36 36 … (0x36)Inner hasha2e48586 3d27f9d8 …XOR with opad16393a39 5c 5c … (0x5c)Outer hash5bdcc146 bf60754e …as RFC 4231 test case 2

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  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.
  3. 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.
  4. 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.
  5. The outer key. The same padded key is XORed with 0x5c, the opad byte, giving 16393a39 followed by 60 bytes of 5c.
  6. 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 from SHA-256, checked against RFC 4231 Python · by-hand/hmac_by_hand.py
"""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

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

  1. Question 1 of 2 An HMAC-SHA-256 key is 100 bytes long. After HMAC has prepared it, how many bytes at the start of the 64-byte block are not zero padding?

    Type a number.

    A hint

    The block of SHA-256 is 64 bytes, and RFC 2104 says what happens to a key longer than a block before it is padded.

    Show the answer: Question 1 of 2

    Answer: 32 bytes

    100 bytes is more than SHA-256's 64-byte block, so the key is first replaced by its SHA-256, 32 bytes. Those 32 bytes are then padded with 32 zero bytes to fill the block, and that block is XORed with ipad and opad.

  2. Question 2 of 2 By the truncation rule of RFC 2104, what is the shortest tag HMAC-SHA-512 may be cut to?

    Type a number.

    A hint

    The rule has two parts, half the hash output and a fixed floor, and the larger of the two wins.

    Show the answer: Question 2 of 2

    Answer: 256 bits

    RFC 2104 asks for at least half the hash output and at least 80 bits. SHA-512 outputs 512 bits, so half is 256, which is above 80. For HMAC-SHA-256 the same rule gives 128 bits.

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:

Web Crypto and createHmac on two published vectors JavaScript · apis-js/hmac.mjs
// 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:

hmac.new, hmac.digest and compare_digest on the same vectors Python · apis-py/hmac_api.py
"""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

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

  1. Question 1 of 2 Which call computes the expected tag and compares it with the received one inside the platform, so that no comparison of your own is involved?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: crypto.subtle.verify("HMAC", key, receivedTag, data)

    Web Crypto's verify takes the received tag as an argument and does the comparison itself. Every other call shown only computes a tag, which you then compare with crypto.timingSafeEqual or hmac.compare_digest.

  2. Question 2 of 2 What does this line do in CPython 3.14.8?

    Read the code, then choose one answer.

    import hmac
    tag = hmac.new(b"Jefe", "what do ya want for nothing?", "sha256").hexdigest()
    Show the answer: Question 2 of 2

    Answer: It raises TypeError: Strings must be encoded before hashing

    hmac.new takes bytes for both the key and the message. Passing a str for the message raises TypeError with the message "Strings must be encoded before hashing", so a receiver must hash the raw bytes it received, not a decoded string.

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:

Interactive · line by line Two ways to compare a tag, on a guess with 2 right bytes The flag under each list is the pair the loop reads next. Watch where the first loop stops, and how far the second one reads.

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

After line 20: expected is ['5b', 'dc', 'c1', '46']; received is ['5b', 'dc', '00', '00']; read is 4; equal is False; e is '46'; r is '00'; diff is 199.expected'5b'0'dc'1'c1'2'46'3received'5b'0'dc'1'00'2'00'3read4equalFalsee'46'r'00'diff199
  • just taken by the loop
  • changed in this step
  • passed by the loop
  • read next
  • where the loop stopped

Printed so far

early 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.

Line run 20
Next line end
Loop pass –
Every step as text
  1. Start. No line has run yet, so there are no variables. Python starts with line 3.
  2. Line 3. expected = ["5b", "dc", "c1", "46"] makes expected a list of 4 items.
  3. Line 4. received = ["5b", "dc", "00", "00"] makes received a list of 4 items.
  4. Line 6. read = 0 sets read to 0.
  5. Line 7. equal = True sets equal to True.
  6. Line 8. for e, r in zip(expected, received): takes the items at position 0 of expected and received together: e is '5b' and r is '5b'.
  7. Line 9. read += 1 changes read from 0 to 1.
  8. Line 10. e != r is false (e is '5b' and r is '5b'), so Python skips the block under if e != r:.
  9. Line 8. for e, r in zip(expected, received): takes the items at position 1 of expected and received together: e is 'dc' and r is 'dc'.
  10. Line 9. read += 1 changes read from 1 to 2.
  11. Line 10. e != r is false (e is 'dc' and r is 'dc'), so Python skips the block under if e != r:.
  12. Line 8. for e, r in zip(expected, received): takes the items at position 2 of expected and received together: e is 'c1' and r is '00'.
  13. Line 9. read += 1 changes read from 2 to 3.
  14. Line 10. e != r is true (e is 'c1' and r is '00'), so the block under if e != r: runs.
  15. Line 11. equal = False sets equal to False (it was True).
  16. Line 12. break leaves 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.
  17. Line 13. print("early exit:", equal, "after", read) prints early exit: False after 3. read is 3: the work of the early exit depended on the guess.
  18. Line 15. read = 0 sets read to 0 (it was 3).
  19. Line 16. diff = 0 sets diff to 0.
  20. Line 17. for e, r in zip(expected, received): takes the items at position 0 of expected and received together: e is '5b' and r is '5b'.
  21. Line 18. read += 1 changes read from 0 to 1.
  22. Line 19. diff |= int(e, 16) ^ int(r, 16) diff keeps its value, 0.
  23. Line 17. for e, r in zip(expected, received): takes the items at position 1 of expected and received together: e is 'dc' and r is 'dc'.
  24. Line 18. read += 1 changes read from 1 to 2.
  25. Line 19. diff |= int(e, 16) ^ int(r, 16) diff keeps its value, 0.
  26. Line 17. for e, r in zip(expected, received): takes the items at position 2 of expected and received together: e is 'c1' and r is '00'.
  27. Line 18. read += 1 changes read from 2 to 3.
  28. Line 19. diff |= int(e, 16) ^ int(r, 16) changes diff from 0 to 193.
  29. Line 17. for e, r in zip(expected, received): takes the items at position 3 of expected and received together: e is '46' and r is '00'.
  30. Line 18. read += 1 changes read from 3 to 4.
  31. Line 19. diff |= int(e, 16) ^ int(r, 16) changes diff from 193 to 199. 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.
  32. Line 17. for e, r in zip(expected, received): stops: expected and received have no item at position 4, so zip makes no more pairs and the loop ends.
  33. Line 20. print("every pair:", diff == 0, "after", read) prints every 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?

Byte comparisons made by an early exit and by a full check Python · leak/compare_leak.py
"""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

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:

timingSafeEqual, lenient hex decoding and a safe wrapper JavaScript · compare-js/compare.mjs
// 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

  1. Question 1 of 2 With the early-exit check of the counting program, how many byte comparisons does a wrong 32-byte guess make when exactly its first 20 bytes are right?

    Type a number.

    A hint

    It compares the 20 right bytes, then one more.

    Show the answer: Question 1 of 2

    Answer: 21 comparisons

    It compares the 20 bytes that match and then the 21st, which differs, and returns. The full check makes 32 comparisons for this guess and every other one.

  2. Question 2 of 2 In Node.js a received tag decodes to 31 bytes, while HMAC-SHA-256 tags are 32. What should the check do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Return false before calling timingSafeEqual

    Check the format and the length first and return false for anything malformed; only tags of the right length reach timingSafeEqual, which then compares them without a short-circuit.

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.

  1. HMAC for any fixed-size hash, block size included To do
  2. 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 byte 0x36 and opad the byte 0x5c repeated over the block.
  • Extendable-output functions (shake_128, shake_256) have no fixed digest size, so raise ValueError for 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.

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 True only for sha256= followed by exactly 64 hex digits, in lower or upper case, that encode the right tag. Anything else returns False: 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.

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.

HMAC Generator Compute HMAC-SHA-256 of your own text and key, and compare it with RFC 4231's test case 2.

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.

  1. Question 1 of 13 A received tag checks out under the shared key. What may the receiver conclude?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: The bytes are unchanged and were tagged by someone who holds the key

    A valid tag shows that the bytes are unchanged since a holder of the key tagged them. It does not say which key holder, it does not hide the message, and it does not show that the message is new.

  2. Question 2 of 13 An attacker records a valid tagged request, "pay 500 to account 77", and sends it again an hour later. Which part of a MAC-based design stops the second payment?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: None of the MAC itself; the design needs a timestamp or an identifier inside the tagged bytes, checked by the receiver

    A MAC proves origin and integrity, not freshness. A replayed message is unchanged and carries a genuine tag, so it verifies; the receiver must tag and check a timestamp, a nonce or an event identifier as well.

  3. Question 3 of 13 In HMAC, which byte is XORed into every byte of the padded key before the inner hash?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: 0x36, the ipad byte

    HMAC(K, m) = H((K′ ⊕ opad) ∥ H((K′ ⊕ ipad) ∥ m)) with ipad = 0x36 and opad = 0x5c repeated over the block. For the key Jefe, 4a becomes 7c after the XOR with 0x36.

  4. Question 4 of 13 HMAC-SHA-512 is given a 100-byte key. What happens to the key before the XOR with ipad?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: It is padded with 28 zero bytes to SHA-512's 128-byte block

    The rule depends on the hash's block size, not on 64. SHA-384 and SHA-512 have 128-byte blocks, so keys of up to 128 bytes are padded and only longer keys, such as RFC 4231's 131-byte key, are hashed first.

  5. Question 5 of 13 A team wants to put HMAC-SHA-256 tags in short links and proposes keeping only the first 64 bits. What is the right reply?
    Show the answer: Question 5 of 13

    Answer: RFC 2104 asks for at least 128 bits here, half the output; at 64 bits a random guess wins once in 2⁶⁴, so keep 128 or put the tag elsewhere

    A forger who guesses at random succeeds once in 2ᵗ tries against a t-bit tag. RFC 2104 sets the minimum at the larger of half the hash output and 80 bits, which is 128 bits for HMAC-SHA-256.

  6. Question 6 of 13 Your own HMAC-SHA-256 gives RFC 4231's tag for test case 2 but not for test case 6, whose key is 131 bytes. What is the most likely bug?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: Keys longer than the 64-byte block are not hashed first

    Test vectors are chosen to reach different rules: case 2 a short key, case 5 a truncated tag, cases 6 and 7 keys longer than a block. A failure in one case points at the rule that only that case exercises.

  7. Question 7 of 13 What does GitHub put in its X-Hub-Signature-256 header?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: sha256= followed by the HMAC-SHA-256 of the body as 64 hex digits

    For the test secret "It's a Secret to Everybody" and the body "Hello, World!" the header is sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17, which all three programs reproduced.

  8. Question 8 of 13 What does CPython 3.14.8 do with hmac.new(key, message, "shake_128")?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: It raises ValueError when the HMAC object is made

    HMAC is defined over a hash with a fixed output and block size. Python's hmac module accepts any such hash and rejects SHAKE128 and SHAKE256: CPython 3.14.8 raises ValueError as soon as you construct one, as the program's last line showed, while the browser build on this page waits until digest() and raises TypeError.

  9. Question 9 of 13 You move a webhook check from Python to a Web Crypto runtime. Which design needs the least comparison code of your own and stays constant-time?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: Decode the received hex tag strictly, check it is 32 bytes, then call crypto.subtle.verify with it

    With Web Crypto the safest code is the shortest: import the secret as an HMAC key with the verify usage and pass the received tag to subtle.verify after checking its format and length.

  10. Question 10 of 13 Why is a comparison that returns at the first differing byte unsafe for checking tags?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: Its running time grows with the number of leading bytes that are right

    A check that stops early does less work for worse guesses. A constant-time check reads every byte and combines the differences, so it does the same work for every guess of the right length.

  11. Question 11 of 13 A colleague writes their own Python loop that ORs together the XOR of every byte pair, instead of calling hmac.compare_digest. What do you say?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: Use hmac.compare_digest; the interpreter promises nothing about the timing of a Python loop, while compare_digest is written not to short-circuit

    Write the comparison with the platform's function: hmac.compare_digest in Python, crypto.timingSafeEqual or subtle.verify in JavaScript. A hand-written loop is useful to show the idea, as the lesson's counting program does, but not to rely on.

  12. Question 12 of 13 What does hmac.compare_digest return for a 32-byte expected tag and a 31-byte received one?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: False

    Python and Node.js treat different lengths differently: compare_digest returns False, timingSafeEqual throws. Checking the length yourself first makes both behave the same way.

  13. Question 13 of 13 Which automated test would fail on an early-exit comparison and pass on a constant-time one?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: Count the work done for wrong guesses whose first 0 to 31 bytes are right, and require one count for all of them

    What matters is whether the work depends on how much of the guess is right. A test that varies exactly that, the length of the right prefix, and requires the same work every time catches the leak deterministically.

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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, createHmac and Python’s hmac gave 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.timingSafeEqual or subtle.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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.