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

Designing signed messages: context and replay

Decide which bytes a MAC covers, bind version, key ID, purpose and recipient, stop replays with signed times and IDs, rotate keys and pick a MAC or a signature.

  • Intermediate
  • 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

  • Decide exactly which bytes a MAC or signature covers, including version, key ID and purpose
  • Prevent replay with signed timestamps, a tolerance window and identifiers seen before
  • Rotate keys without refusing messages that are still in flight
  • Choose between a shared-key MAC and a public-key signature for a given integration

Before you start

What you’ll learn · Before you start

What you will learn

  • Decide exactly which bytes a MAC or signature covers, including version, key ID and purpose
  • Prevent replay with signed timestamps, a tolerance window and identifiers seen before
  • Rotate keys without refusing messages that are still in flight
  • Choose between a shared-key MAC and a public-key signature for a given integration

Before you start

On this page

A webhook says that invoice 7731 is paid, and its HMAC-SHA-256 tag checks out. That proves a holder of the key made these bytes at some point. It does not prove that they were meant for this endpoint or this purpose, or that this is the first time they have arrived: a copy delivered again an hour later carries the same valid tag.

This lesson designs the message around the MAC: which bytes the tag covers and how they are framed, the version, key ID, purpose and recipient that tie a tag to one use, the times, windows and caches that make a copy worthless, rotating keys without refusing messages in flight, and the choice between a shared key and a signature. A two-step code lab at the end builds a signed envelope and a receiver that accepts it once.

Authenticate the bytes you received, framed so they read one way

A MAC is computed over bytes, so the first decision is which bytes. Verify the request body exactly as it arrived, before any framework parses it: parsing JSON and writing it out again changes spacing, key order and the spelling of numbers, and the hashing module showed two texts of the same data with different digests. Providers say the same in their own words. Stripe requires the raw body and warns that any change to it makes verification fail, and Razorpay’s documentation says not to parse or cast the body before validating it. If a design must tag structured data rather than raw bytes, it needs a canonical form, such as RFC 8785 for JSON, applied the same way by both sides.

Not affiliated

Based on public information about GitHub, Stripe and Razorpay. MySmartCoPilot is not affiliated with GitHub, Stripe and Razorpay, and GitHub, Stripe and Razorpay’s real webhook formats may differ from what this lesson describes.

When the tag covers fields beside the body, such as a key ID or a time, the fields must be joined so that the bytes read back only one way. This lesson’s envelope frames every field by its length, as the hashing module framed its download links, and writes the time as 8 bytes:

Field Value in the example Bytes
label and version msc-envelope/v1 4 + 15
key ID k2 4 + 2
time, in Unix seconds 1,700,000,000 8
nonce 16 random bytes 4 + 16
recipient orders 4 + 6
body the raw JSON 4 + 39

The program builds those 106 bytes, tags them with HMAC-SHA-256, then changes one field at a time:

A framed envelope, tagged, then changed one field at a time JavaScript · envelope-js/envelope.mjs
// A signed envelope in Node.js 24: a label naming the format and its version, the key ID, the time, a nonce, the
// recipient and the raw body, each framed by its length, then HMAC-SHA-256 over all of it. The clock, the nonce and
// the key are fixed so that the output is the same on every run; a real sender reads its clock, takes 16 bytes from
// crypto.getRandomValues() and loads the key from its secrets store.
import { createHmac, timingSafeEqual } from 'node:crypto';

const LABEL = 'msc-envelope/v1';
const KEY_K2 = Buffer.alloc(32, 0x42); // lab key, never a real one
const enc = new TextEncoder();

/** A 4-byte big-endian length, then the bytes. */
const framed = (bytes) => {
  const out = Buffer.alloc(4 + bytes.length);
  out.writeUInt32BE(bytes.length, 0);
  Buffer.from(bytes).copy(out, 4);
  return out;
};

function encode({ kid, time, nonce, recipient, body }) {
  const t = Buffer.alloc(8);
  t.writeBigUInt64BE(BigInt(time));
  return Buffer.concat([framed(enc.encode(LABEL)), framed(enc.encode(kid)), t, framed(nonce), framed(enc.encode(recipient)), framed(body)]);
}

const message = {
  kid: 'k2',
  time: 1_700_000_000, // Unix time in seconds, as the sender's clock read it
  nonce: Buffer.from('000102030405060708090a0b0c0d0e0f', 'hex'),
  recipient: 'orders',
  body: enc.encode('{"event":"invoice.paid","invoice":7731}'),
};
const bytes = encode(message);
const tag = createHmac('sha256', KEY_K2).update(bytes).digest();
console.log(`signed bytes: ${bytes.length} (label 4+15, key ID 4+2, time 8, nonce 4+16, recipient 4+6, body 4+${message.body.length})`);
console.log(`tag: ${tag.toString('hex')}`);

// Change one field at a time: every change makes the receiver's recomputed tag differ.
const variants = [
  ['as sent', message],
  ['recipient refunds', { ...message, recipient: 'refunds' }],
  ['key ID k1', { ...message, kid: 'k1' }],
  ['time one second later', { ...message, time: message.time + 1 }],
  ['body with a space', { ...message, body: enc.encode('{"event":"invoice.paid", "invoice":7731}') }],
];
for (const [label, m] of variants) {
  const again = createHmac('sha256', KEY_K2).update(encode(m)).digest();
  console.log(`${label.padEnd(22)} tag matches: ${timingSafeEqual(again, tag)}`);
}

Output

signed bytes: 106 (label 4+15, key ID 4+2, time 8, nonce 4+16, recipient 4+6, body 4+39)
tag: f5b64040500157ce7fbad8f1aac0ca3cfd99a82d0aebedecb59226abc7e9588a
as sent                tag matches: true
recipient refunds      tag matches: false
key ID k1              tag matches: false
time one second later  tag matches: false
body with a space      tag matches: false

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node envelope.mjs

Every change, even one second of time or one space in the body, gives a different tag, so the receiver’s recomputed tag no longer matches. Stripe’s format shows a lighter framing that also reads one way: it tags the timestamp, a full stop and the raw body, and since the timestamp is all digits and comes first, the first full stop always ends it.

Verifying the body after the framework parsed it

Wrong: verify(json.dumps(request.json())), which tags bytes the sender never sent and fails whenever spacing or key order differ. Right: read the raw bytes, verify them, and parse only what passed.

Checkpoint

Check: Which bytes a tag covers, and how they are framed

  1. Question 1 of 2 A web framework has already parsed a webhook's JSON body into an object. Which bytes should the receiver verify the tag against?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The raw body exactly as it arrived, read before or beside the parsing

    A MAC is computed over bytes. Verify the raw body the sender tagged, and parse only after the tag has passed. Stripe and Razorpay both require the raw body for this reason.

  2. Question 2 of 2 A design tags kid + "|" + recipient + "|" + body. Which pair of messages gets the same tag?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: kid "k2", recipient "a|b" and kid "k2|a", recipient "b", with the same body

    The tag is only as unambiguous as the bytes. Framing each field by its length, as the envelope does, means two different lists of fields can never produce the same bytes.

Bind the tag to one use: version, key ID, purpose and recipient

A tag says that a key holder made the bytes, not what the bytes were for. If one key tags invoice events for both the orders service and the refunds service, or both webhook messages and sign-in links, a message made for one use can be offered to another and its tag will verify. Every field that decides how the receiver acts belongs inside the tagged bytes:

  • Version: the format of the message. A receiver refuses versions it does not know and never falls back to an older, weaker one; Stripe tells receivers to ignore every signature scheme except v1 to prevent downgrade attacks.
  • Key ID: the name of the key to check with, which makes rotation possible. The receiver looks it up in its own key ring and refuses names it does not have.
  • Purpose: a label such as msc-envelope/v1, so that the tag of one kind of message is never valid as another kind. This is the domain separation: Making the purpose part of what is hashed or signed, for example with a label such as download-link/v1 in front of the fields, so that a value made for one purpose is never accepted for another. More in “Integrity checks, SRI and canonical hashing” of the hashing module.
  • Recipient: the receiver rebuilds the bytes with its own name, so a message for orders fails at refunds.

The Python version of the envelope gives the same 106 bytes and the same tag as the JavaScript one, so either language verifies the other’s messages. It then plays two receivers and a second use of the same key:

The same envelope in Python, at two receivers and as a sign-in link Python · envelope-py/envelope.py
"""The same envelope in Python: the bytes and the tag equal the JavaScript program's, so either language can verify the
other's messages. The receiver rebuilds the bytes with its own name and the label it expects, so a tag made for
another recipient or another use does not verify here."""
import hmac

KEY_K2 = bytes([0x42] * 32)  # lab key, never a real one


def framed(data: bytes) -> bytes:
    return len(data).to_bytes(4, "big") + data


def encode(label: str, kid: str, time: int, nonce: bytes, recipient: str, body: bytes) -> bytes:
    return b"".join([framed(label.encode()), framed(kid.encode()), time.to_bytes(8, "big"), framed(nonce), framed(recipient.encode()), framed(body)])


nonce = bytes(range(16))
body = b'{"event":"invoice.paid","invoice":7731}'
sent = encode("msc-envelope/v1", "k2", 1_700_000_000, nonce, "orders", body)
tag = hmac.digest(KEY_K2, sent, "sha256")
print(f"signed bytes: {len(sent)}")
print(f"tag: {tag.hex()}")


def accepted_by(receiver: str, label: str, tag: bytes) -> bool:
    """A receiver named `receiver` that expects messages of the format `label` rebuilds the bytes and checks the tag."""
    expected = hmac.digest(KEY_K2, encode(label, "k2", 1_700_000_000, nonce, receiver, body), "sha256")
    return hmac.compare_digest(expected, tag)


print(f"\nat orders, as an envelope:       {accepted_by('orders', 'msc-envelope/v1', tag)}")
print(f"at refunds, as an envelope:      {accepted_by('refunds', 'msc-envelope/v1', tag)}")

# The same key also tags sign-in links under another label. A link's tag is never accepted as an envelope's.
link = encode("msc-link/v1", "k2", 1_700_000_000, nonce, "orders", body)
link_tag = hmac.digest(KEY_K2, link, "sha256")
print(f"a link's tag offered as envelope: {accepted_by('orders', 'msc-envelope/v1', link_tag)}")
print(f"the link's tag as a link:         {hmac.compare_digest(link_tag, hmac.digest(KEY_K2, link, 'sha256'))}")

Output

signed bytes: 106
tag: f5b64040500157ce7fbad8f1aac0ca3cfd99a82d0aebedecb59226abc7e9588a

at orders, as an envelope:       True
at refunds, as an envelope:      False
a link's tag offered as envelope: False
the link's tag as a link:         True

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 envelope.py

The tag verifies at orders and fails at refunds, which rebuilt the bytes with its own name, and a sign-in link tagged with the same key under the label msc-link/v1 is never accepted as an envelope. A separate key per purpose gives the same separation and limits what one leaked key exposes; a label inside the bytes still protects you on the day a key is shared by mistake.

Checkpoint

Check: Binding a tag to one use

  1. Question 1 of 2 The orders and the refunds services share one webhook key, and messages carry no recipient. What can someone do with a valid message captured on its way to orders?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Send it to refunds, where its tag verifies

    Bind the recipient: the receiver rebuilds the tagged bytes with its own name, so a message for orders fails at refunds. A separate key per receiver also stops it, and limits what one leaked key exposes.

  2. Question 2 of 2 A header can carry signatures under several schemes, v1 and an older v0. What should a receiver that knows v1 do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Check only v1 signatures and ignore every other scheme

    A version names a format the receiver understands. Refuse versions you do not know and never fall back to a weaker one; otherwise the weakest scheme in the header decides what is accepted.

A valid tag can arrive twice: timestamps, windows and replay caches

A copy of a tagged message is still a tagged message. Networks repeat requests, providers retry deliveries, and anyone who has recorded a message can send it again. This is replay: Sending a captured, valid message again. A MAC or a signature still verifies on the copy, so a design needs its own freshness check, such as a timestamp, a nonce or a record of what was accepted. More in “What cryptography can and cannot do”, and no MAC on its own can tell the copy from the original. Three tools make a copy worthless, and the receiver below uses all three:

  • a time inside the tagged bytes, refused when it falls outside a replay window: The span around the receiver's clock in which a message's signed time must fall to be accepted, such as up to 300 seconds behind and 30 ahead. Older copies are refused even though their tags are valid. around the receiver’s clock;
  • a nonce, a random value for each message, kept in a replay cache: A receiver's record of the nonces it has accepted, kept as long as their messages could still pass the replay window, so that a second copy of a valid message is refused. for as long as its message could still pass the window;
  • an event identifier that the application records once it has acted, because a provider’s retry is a new message about the same event.

Step through five deliveries of one invoice event:

Interactive · message by message One event, five deliveries, one action Step through what the receiver does with each delivery of the same invoice event. The tag checks out every time; the time window, the nonce cache and the event ID decide.
One event, five deliveries, one action, step 6 of 6. Messages so far: Sender to Receiver: t, n-1, evt_1; Receiver to Replay cache: keep n-1 to t + 300; Sender to Receiver: same bytes at t + 42; Sender to Receiver: t + 88, n-2, evt_1; Sender to Receiver: time 2 min ahead; Sender to Receiver: same bytes at t + 360.Sendertags timeReceiverwindow: 300 s back, 30 aheadReplay cachen-1 and n-2t, n-1, evt_1keep n-1 to t + 300same bytes at t + 42t + 88, n-2, evt_1time 2 min aheadsame bytes at t + 360refused: older than window

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

Every step as text
  1. First delivery. The message has time t, nonce n-1 and event evt_1, and arrives 2 seconds later. The tag verifies, the time is inside the window and n-1 is new, so the receiver acts on evt_1.
  2. Remember the nonce. The receiver stores n-1 until t + 300 s. After that the window alone refuses the message, so the cache never has to hold it longer.
  3. The same bytes again. At t + 42 the identical bytes arrive again, from a network retry or a copy. The tag is still valid and the time still inside the window, but n-1 is in the cache: refused as a replay.
  4. A retry, newly tagged. At t + 90 a retry arrives with a new time, t + 88, a new nonce n-2 and a fresh tag. The receiver accepts it, then sees that evt_1 was done already and skips the work.
  5. A clock running fast. At t + 100 a message whose time is 2 minutes ahead of the receiver's clock is refused: the window allows 30 seconds ahead. Keep clocks synchronised with NTP so this only catches what it should.
  6. Too old. At t + 360 the same bytes are 6 minutes old, past the 300-second window, so they are refused before the cache is even read. The window alone stops them, which is why n-1 only had to be kept until t + 300.

The program is that receiver, with a clock you set. Run it, then change the times or the window:

A receiver with a window, a nonce cache and event IDs Python · replay-py/replay.py
"""A receiver that accepts a tagged message once: its tag must verify, its time must be within five minutes of the
receiver's clock (30 seconds ahead at most), and its nonce must not have been seen while it could still be accepted.
The clock is passed in, so every delivery below happens at a time the program chooses."""
import hmac
import json

KEY = bytes([0x42] * 32)  # lab key
WINDOW = 300  # seconds a message stays acceptable after its time
AHEAD = 30  # seconds a sender's clock may be ahead of ours


def tag_of(time: int, nonce: str, body: bytes) -> bytes:
    data = time.to_bytes(8, "big") + len(nonce).to_bytes(4, "big") + nonce.encode() + body
    return hmac.digest(KEY, data, "sha256")


class Receiver:
    def __init__(self):
        self.seen = {}  # nonce -> the time after which its message is refused anyway

    def accept(self, now: int, time: int, nonce: str, body: bytes, tag: bytes) -> str:
        if not hmac.compare_digest(tag_of(time, nonce, body), tag):
            return "refused: bad tag"
        if time > now + AHEAD:
            return "refused: time is in the future"
        if now - time > WINDOW:
            return "refused: older than the window"
        self.seen = {n: until for n, until in self.seen.items() if until >= now}  # forget what can no longer pass
        if nonce in self.seen:
            return "refused: replay"
        self.seen[nonce] = time + WINDOW
        return "accepted"


r = Receiver()
t0 = 1_700_000_000
body = b'{"event":"invoice.paid","invoice":7731,"id":"evt_1"}'
first = (t0, "n-1", body, tag_of(t0, "n-1", body))
deliveries = [  # in the order the receiver's clock meets them
    ("first delivery, 2 s in transit", t0 + 2, first),
    ("same bytes again, 40 s later", t0 + 42, first),
    ("a retry: new time and nonce, same event", t0 + 90, (t0 + 88, "n-2", body, tag_of(t0 + 88, "n-2", body))),
    ("sender's clock 2 minutes fast", t0 + 100, (t0 + 220, "n-3", body, tag_of(t0 + 220, "n-3", body))),
    ("time changed, tag kept", t0 + 120, (t0 + 119, "n-1", body, first[3])),
    ("same bytes, 6 minutes later", t0 + 360, first),
]
processed = set()  # the application's own record of event IDs it has acted on
for label, now, (time, nonce, data, tag) in deliveries:
    verdict = r.accept(now, time, nonce, data, tag)
    if verdict == "accepted":
        event = json.loads(data)["id"]
        verdict = f"accepted; {event} already done, skipped" if event in processed else f"accepted; {event} processed"
        processed.add(event)
    print(f"{label:<42} {verdict}")
print(f"\nnonces remembered: {len(r.seen)}; each is kept {WINDOW} s after its message's time")

Output

first delivery, 2 s in transit             accepted; evt_1 processed
same bytes again, 40 s later               refused: replay
a retry: new time and nonce, same event    accepted; evt_1 already done, skipped
sender's clock 2 minutes fast              refused: time is in the future
time changed, tag kept                     refused: bad tag
same bytes, 6 minutes later                refused: older than the window

nonces remembered: 2; each is kept 300 s after its message's time

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 replay.py

The window and the cache work as a pair. The window decides how long the cache must remember a nonce, so the cache holds only the messages of the last 300 seconds: at 200 messages a second that is 60,000 nonces, about 0.96 MB of 16-byte values before any overhead, and a replay older than the window is refused without reading the cache at all. Set the window for clock differences and delivery time, not for convenience. Stripe’s libraries use five minutes by default, ask receivers to keep their clocks synchronised with NTP and warn that a tolerance of 0 turns the time check off; because Stripe signs a new timestamp for every retry, its documentation also says to log the event IDs you have processed. GitHub’s and Razorpay’s signatures cover the body alone, with no time in them. Razorpay sends an x-razorpay-event-id header that is unique per event and asks receivers to check whether that event was already processed, so with a body-only signature the event ID is the one defence against a replayed delivery.

Key idea

A MAC proves who made the bytes; a signed time, a window and a record of what was accepted prove that the receiver has not acted on them before.

Checkpoint

Check: Stopping replayed messages

  1. Question 1 of 2 A receiver accepts messages for 600 seconds after their time and takes 50 messages a second. How many nonces does its replay cache hold at most, if every message is new?

    Type a number.

    A hint

    A nonce is needed only while its message could still pass the window.

    Show the answer: Question 1 of 2

    Answer: 30000 nonces

    Each nonce is kept as long as its message can pass the window, 600 seconds, so at 50 messages a second the cache holds about 50 × 600 = 30,000 nonces. Older nonces can be forgotten, because the window alone refuses their messages.

  2. Question 2 of 2 A provider signs only the body, with no timestamp. What lets your receiver refuse a delivery that is replayed a day later?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Recording the event ID of every event you have processed and skipping those that come again

    Without a signed time, freshness has to come from what the receiver remembers. Record each processed event ID, and treat a delivery whose ID you have already processed as done.

Rotate keys with an overlap, and try the key the message names

Keys are replaced on a schedule, and at once after a suspected leak, while messages are in flight. A key rotation: Replacing a key with a new one, on a schedule or after a suspected leak, with an overlap in which receivers accept both, so that messages made with the old key and still in flight are not refused. that refuses those messages loses events; one that accepts the old key for ever never finishes. The order that avoids both is short: receivers learn the new key first, senders switch, both keys verify during an overlap, and receivers forget the old key last.

Timeline · 5 events in order Rotating an HMAC key without refusing a message in flight Walk through the phases in order. Receivers learn the new key first and forget the old one last.
  1. Before

    Every sender tags with k1

    Senders and receivers hold one key, k1, and every message names k1 as its key ID inside the tagged bytes.

  2. Phase 1

    Receivers add k2 receivers first

    Every receiver adds k2 to its key ring and accepts messages that name either key. Senders still use k1, so nothing changes on the wire yet.

  3. Phase 2

    Senders switch to k2

    At time T the senders tag new messages with k2. Messages tagged with k1 just before T are still in flight or waiting for a retry, and they still verify.

  4. Overlap

    Both keys verify for a set time

    Receivers accept k1 until T plus the overlap, which must be longer than the sender keeps retrying old messages. Stripe lets two secrets stay active for up to 24 hours, signing each delivery once with each.

  5. Phase 3

    Receivers drop k1 old key last

    After the overlap, k1 leaves every key ring and its copies are destroyed. A message that still names k1 is refused as from a retired key, and an unknown key ID is refused as well.

The timeline has 5 events, from Before to Phase 3.

Each message carries a key identifier (key ID): A short public name for a key, such as k2, carried in a tagged or signed message so that the receiver knows which key of its own key ring to check with. RFC 9421 calls it keyid and JOSE calls it kid. inside its tagged bytes, and the receiver looks it up in its own key ring. The program runs a rotation from k1 to k2 with a one-day overlap:

A key ring during and after a rotation JavaScript · rotation-js/rotation.mjs
// Rotating an HMAC key with an overlap. The receiver keeps a key ring: each key has an ID and the time after which it
// is no longer accepted. A message names its key ID inside the tagged bytes, the receiver looks the ID up in its own
// ring (never trusting a key the message brings), and an unknown or retired ID is refused.
import { createHmac, timingSafeEqual } from 'node:crypto';

const k1 = Buffer.alloc(32, 0x11); // lab keys, never real ones
const k2 = Buffer.alloc(32, 0x22);
const T = 1_700_000_000; // the moment the senders switch from k1 to k2 (Unix seconds)
const OVERLAP = 24 * 3600; // receivers keep accepting k1 for a day after the switch

const ring = new Map([
  ['k1', { key: k1, acceptUntil: T + OVERLAP }],
  ['k2', { key: k2, acceptUntil: Infinity }],
]);

// The key ID is part of what is tagged. It comes first, and an ID in the ring never holds a newline, so the first
// newline always ends it: the bytes read back one way, as framing by length would also ensure.
const bytesOf = (kid, body) => Buffer.from(`${kid}\n${body}`);
const sign = (kid, key, body) => ({ kid, body, tag: createHmac('sha256', key).update(bytesOf(kid, body)).digest() });

function verify(msg, now) {
  const entry = ring.get(msg.kid);
  if (!entry) return 'refused: unknown key ID';
  if (now > entry.acceptUntil) return `refused: ${msg.kid} retired`;
  const expected = createHmac('sha256', entry.key).update(bytesOf(msg.kid, msg.body)).digest();
  return timingSafeEqual(expected, msg.tag) ? `accepted with ${msg.kid}` : 'refused: bad tag';
}

const beforeSwitch = sign('k1', k1, 'order 1 shipped'); // made just before the switch, delivered by a retry later
const afterSwitch = sign('k2', k2, 'order 2 shipped');
const relabelled = { ...sign('k1', k1, 'order 3 shipped'), kid: 'k2' }; // a k1 tag that claims to be from k2
const unknown = sign('k9', Buffer.alloc(32, 0x99), 'order 4 shipped');

const cases = [
  ['k1 message, an hour after the switch', beforeSwitch, T + 3600],
  ['k2 message, an hour after the switch', afterSwitch, T + 3600],
  ['k1 message, a retry two days later', beforeSwitch, T + 2 * 24 * 3600],
  ['k2 message, two days later', afterSwitch, T + 2 * 24 * 3600],
  ['k1 tag with its key ID changed to k2', relabelled, T + 3600],
  ['a key ID the ring does not have', unknown, T + 3600],
];
for (const [label, msg, now] of cases) console.log(`${label.padEnd(38)} ${verify(msg, now)}`);

Output

k1 message, an hour after the switch   accepted with k1
k2 message, an hour after the switch   accepted with k2
k1 message, a retry two days later     refused: k1 retired
k2 message, two days later             accepted with k2
k1 tag with its key ID changed to k2   refused: bad tag
a key ID the ring does not have        refused: unknown key ID

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node rotation.mjs

A message tagged with k1 just before the switch still verifies an hour later, but a retry of it two days later is refused, because the overlap is over. A k1 tag whose key ID was changed to k2 fails, because the key ID is part of what was tagged, and an ID the ring does not have is refused before any key is tried. Providers set the overlap from their retries. Stripe lets two secrets stay active for up to 24 hours and sends one signature per secret meanwhile, and Razorpay’s documentation tells receivers to validate older retried requests with the old secret. Keep the old key at least as long as the sender keeps retrying old messages.

Taking the key from the message

Wrong: a message that carries its key, or the address to fetch it from, and a receiver that uses it. Anyone could then tag a message with a key of their own. Right: the message only names a key ID, and the receiver looks it up among the keys it already holds.

Checkpoint

Check: Rotating keys with an overlap

  1. Question 1 of 2 In which order do you rotate an HMAC key shared by one sender and many receivers?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Receivers add the new key; senders switch; both keys verify during an overlap; receivers drop the old key

    Receivers learn the new key first and forget the old one last. The overlap between the switch and the end covers messages that were tagged with the old key and are still on their way or waiting for a retry.

  2. Question 2 of 2 A provider signs only the body and retries failed deliveries for up to three days, re-sending the body and the signature it first made. How long must receivers keep accepting the old key after the provider switches?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: At least three days, the longest a message tagged with the old key can still arrive

    Set the overlap from the sender's retries. A sender that tags each retry afresh, as Stripe does, needs a shorter overlap than one that re-sends the original tag, as Razorpay's retries do.

A shared key or a key pair: choosing a MAC or a signature

With HMAC, the sender and the receiver hold the same key, so either of them could have made a tag. That is fine between two services that trust each other, weak when many receivers share one key, and no evidence at all for a third party. A digital signature: A value made from a message with a private key that anyone holding the matching public key can check. Any change to the message or the signature makes the check fail; Ed25519, ECDSA and RSA-PSS make them. More in “Public-key cryptography: RSA and elliptic curves” is made with a private key and checked with the public one, so only the owner of the private key can sign, anyone can verify, and the public key may be published. The public-key module builds signatures with Ed25519. Answer three questions about the messages you send:

Interactive · answer and compare A shared-key MAC or a signature for your messages? Answer three questions about who checks your messages. The table marks what fits and says why the other choices do not.
How many parties check the messages you send?
Must someone else, such as an auditor, be able to tell that you made a message?
Can every receiver keep a shared secret safe, on servers it controls?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Two ways to share an HMAC key, and a public-key signature, against what each one proves
Choice Who can make a valid tag Convinces a third party Keys to hand out and rotate Cost per message
HMAC, a key per receiver The receiver needs: a secret it shares with you Mixed you and that one receiver Poor either side could have made the tag Mixed one secret per receiver, each sent privately and rotated Good about two SHA-256 calls
HMAC, one key for all The receiver needs: the secret every receiver holds Poor every receiver can make tags that all the others accept Poor any key holder could have made the tag Mixed one secret, but one leak anywhere exposes every receiver Good about two SHA-256 calls
Ed25519 signature The receiver needs: your public key Good only the holder of your private key Good anyone with your public key can check who signed Good one public key, which may be published Mixed more work than HMAC, still small for webhooks
How the answers decide
  • “How many parties check the messages you send?” Many receivers, or ones you do not run: rules out HMAC, one key for all and adds a caveat to HMAC, a key per receiver. With one shared key every receiver could tag messages the others accept; a key per receiver fixes that at the cost of many secrets.
  • “Must someone else, such as an auditor, be able to tell that you made a message?” Yes: rules out HMAC, a key per receiver and HMAC, one key for all. With a shared key either side could have made the tag, so only a signature shows a third party which key holder made it.
  • “Can every receiver keep a shared secret safe, on servers it controls?” No, or I cannot be sure: rules out HMAC, a key per receiver and HMAC, one key for all. A secret that leaks lets anyone make valid tags. A public key needs no secrecy, so a signature survives a careless receiver.
  • “How many parties check the messages you send?” One receiver and “Must someone else, such as an auditor, be able to tell that you made a message?” No and “Can every receiver keep a shared secret safe, on servers it controls?” Yes: adds a caveat to Ed25519 signature. It works, but for one sender and one receiver who share a secret, HMAC is simpler and cheaper.

In short: HMAC with a key per receiver for a service talking to one other that keeps its secret safe, and a signature when many parties verify, when a third party must be convinced, or when a receiver cannot keep a secret. RFC 9421, HTTP Message Signatures, covers both in one format: its algorithms include hmac-sha256 and ed25519, and its signature parameters created, expires, nonce, keyid and tag play the parts of this lesson’s time, window, nonce, key ID and purpose label.

Checkpoint

Check: Choosing a MAC or a signature

  1. Question 1 of 2 A customer forwards your events to their auditor, who must be able to tell that you sent them. What do you sign with?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A signature with your private key, such as Ed25519, which the auditor checks with your public key

    A shared key makes both holders equally able to tag. Evidence for a third party needs a key that only the sender holds, which is what a digital signature uses.

  2. Question 2 of 2 Your service sends events to 300 customer endpoints, and every endpoint verifies with the same HMAC secret. What is the main risk?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Any customer can make messages that the other 299 accept as yours

    With one shared key, the weakest of 300 customers decides who can forge your messages. Use one key per customer, or sign with a private key that only you hold.

Practise: an envelope and a receiver that accepts it once

The lab builds both ends in Python, with sample tests you can run here. Step one frames and tags the lesson’s envelope, held to its 106 bytes and its tag, and to pairs of envelopes whose fields a separator would run together. Step two is the receiver: unknown and retired keys, the tag rebuilt with its own name, both edges of the window, and a cache that forgets old nonces and never grows on a forged message:

Code lab · 2 steps

Build a signed envelope and its receiver

Frame and tag the lesson's envelope, then write the receiver that accepts each one once, inside its window, under a key it holds.

  1. Frame and tag an envelope so its fields read one way To do
  2. Accept a tagged envelope once, inside its window To do

Exercise · Easy · Python

Frame and tag an envelope so its fields read one way

Write encode_envelope(kid, time, nonce, recipient, body) and sign_envelope(key, kid, time, nonce, recipient, body) in envelope.py, so that a tag covers each field of the lesson's envelope and no two different envelopes give the same bytes:

  • The bytes are, in this order: the label msc-envelope/v1, the key ID, the time, the nonce, the recipient and the body. Text is UTF-8. Every field except the time is written as its length in 4 bytes, big-endian, then its bytes; the time is a whole number of seconds written as 8 bytes, big-endian.
  • Raise ValueError for a nonce that is not exactly 16 bytes and for a time that is negative or does not fit in 8 bytes.
  • sign_envelope returns the 32-byte HMAC-SHA-256 of those bytes under key.

The sample tests check the lesson's own message, 106 bytes whose tag starts f5b64040, and pairs of envelopes whose fields a plain separator would run together. The starter code joins the fields with |.

Starter code · envelope.py

import hmac

LABEL = "msc-envelope/v1"


def encode_envelope(kid: str, time: int, nonce: bytes, recipient: str, body: bytes) -> bytes:
    """The bytes the tag covers: the label, then each field of the envelope."""
    parts = [LABEL, kid, str(time), nonce.hex(), recipient, body.decode()]
    return "|".join(parts).encode()  # joined with "|": the fields can run into each other


def sign_envelope(key: bytes, kid: str, time: int, nonce: bytes, recipient: str, body: bytes) -> bytes:
    """The HMAC-SHA-256 of the envelope's bytes under key."""
    return hmac.digest(key, encode_envelope(kid, time, nonce, recipient, body), "sha256")
The sample tests · test_envelope.py
from envelope import encode_envelope, sign_envelope

KEY = bytes([0x42] * 32)  # the lesson's lab key
NONCE = bytes(range(16))
BODY = b'{"event":"invoice.paid","invoice":7731}'


def raises_value_error(call):
    try:
        call()
    except ValueError:
        return True
    return False


def test_lesson_message_bytes():
    """the lesson's message is 106 bytes: framed label and key ID, then 8 bytes of time"""
    data = encode_envelope("k2", 1_700_000_000, NONCE, "orders", BODY)
    assert len(data) == 106
    assert data[:25] == b"\x00\x00\x00\x0fmsc-envelope/v1\x00\x00\x00\x02k2"
    assert data[25:33] == (1_700_000_000).to_bytes(8, "big")
    assert data[33:53] == b"\x00\x00\x00\x10" + NONCE
    assert data[-43:] == len(BODY).to_bytes(4, "big") + BODY


def test_lesson_tag():
    """the tag equals the one the lesson's JavaScript and Python programs print"""
    tag = sign_envelope(KEY, "k2", 1_700_000_000, NONCE, "orders", BODY)
    assert tag.hex() == "f5b64040500157ce7fbad8f1aac0ca3cfd99a82d0aebedecb59226abc7e9588a"


def test_fields_never_run_together():
    """envelopes that a separator or plain joining would confuse give different bytes"""
    pairs = [
        (("k2", 1, NONCE, "a|b", b"x"), ("k2|a", 1, NONCE, "b", b"x")),
        (("k2", 1, NONCE, "orders", b"|x"), ("k2", 1, NONCE, "orders|", b"x")),
        (("k", 1, NONCE, "2orders", b""), ("k2", 1, NONCE, "orders", b"")),
        (("k2", 1, NONCE, "", b"x"), ("k2", 1, NONCE, "x", b"")),
    ]
    for a, b in pairs:
        assert encode_envelope(*a) != encode_envelope(*b), (a, b)


def test_bad_nonce_or_time_refused():
    """a nonce of 15 or 17 bytes, a negative time and a time of 2**64 raise ValueError"""
    assert raises_value_error(lambda: encode_envelope("k2", 1, bytes(15), "orders", b""))
    assert raises_value_error(lambda: encode_envelope("k2", 1, bytes(17), "orders", b""))
    assert raises_value_error(lambda: encode_envelope("k2", -1, NONCE, "orders", b""))
    assert raises_value_error(lambda: encode_envelope("k2", 2**64, NONCE, "orders", b""))


def test_any_change_changes_the_tag():
    """another recipient, key ID, second or body byte gives another tag"""
    base = sign_envelope(KEY, "k2", 1_700_000_000, NONCE, "orders", BODY)
    assert sign_envelope(KEY, "k2", 1_700_000_000, NONCE, "refunds", BODY) != base
    assert sign_envelope(KEY, "k1", 1_700_000_000, NONCE, "orders", BODY) != base
    assert sign_envelope(KEY, "k2", 1_700_000_001, NONCE, "orders", BODY) != base
    assert sign_envelope(KEY, "k2", 1_700_000_000, NONCE, "orders", BODY + b" ") != base
A hint

Write a helper framed(data) that returns len(data).to_bytes(4, "big") + data, and join the framed label, key ID, nonce, recipient and body with the 8-byte time in its place: time.to_bytes(8, "big").

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 · Medium · Python

Accept a tagged envelope once, inside its window

Write verify_envelope(msg, tag, now, ring, seen, me) in receiver.py. It decides whether this receiver, named me, accepts an envelope of the previous step, and returns one word for its decision:

  • msg holds kid, time, nonce and body. ring maps key IDs to {"key": bytes, "accept_until": seconds}; seen maps nonces to the second until which they are kept. now is the receiver's clock in seconds.
  • In this order: "unknown-key" for a key ID the ring does not have; "retired-key" when now is past its accept_until; "bad-tag" when the tag of the envelope rebuilt with me as recipient differs from tag, compared with hmac.compare_digest; "in-the-future" when the time is more than 30 seconds ahead of now; "too-old" when it is more than 300 seconds behind.
  • Then forget every nonce in seen whose keep-until second is before now, return "replay" for a nonce that is still there, and otherwise remember the nonce until time + 300 and return "accepted".

A message that fails any check must leave seen as it was. encode_envelope from the previous step is already in the starter code, which checks the tag and nothing else.

Starter code · receiver.py

import hmac

LABEL = "msc-envelope/v1"
WINDOW = 300  # seconds a message is accepted after its time
AHEAD = 30  # seconds a sender's clock may be ahead of ours


def framed(data: bytes) -> bytes:
    return len(data).to_bytes(4, "big") + data


def encode_envelope(kid: str, time: int, nonce: bytes, recipient: str, body: bytes) -> bytes:
    return b"".join(
        [framed(LABEL.encode()), framed(kid.encode()), time.to_bytes(8, "big"), framed(nonce), framed(recipient.encode()), framed(body)]
    )


def verify_envelope(msg: dict, tag: bytes, now: int, ring: dict, seen: dict, me: str) -> str:
    """One word for the decision: accepted, unknown-key, retired-key, bad-tag, in-the-future, too-old or replay."""
    key = ring[msg["kid"]]["key"]  # raises KeyError for an unknown key ID
    data = encode_envelope(msg["kid"], msg["time"], msg["nonce"], me, msg["body"])
    if not hmac.compare_digest(hmac.digest(key, data, "sha256"), tag):
        return "bad-tag"
    return "accepted"  # no clock, no replay cache, no retired keys
The sample tests · test_receiver.py
import hmac

from receiver import encode_envelope, verify_envelope

K1, K2 = bytes([0x11] * 32), bytes([0x22] * 32)  # lab keys
T = 1_700_000_000
RING = {"k1": {"key": K1, "accept_until": T + 86_400}, "k2": {"key": K2, "accept_until": T + 10**9}}
BODY = b'{"event":"invoice.paid","invoice":7731}'


def message(kid="k2", time=T, nonce=bytes(16), recipient="orders", key=K2):
    msg = {"kid": kid, "time": time, "nonce": nonce, "body": BODY}
    tag = hmac.digest(key, encode_envelope(kid, time, nonce, recipient, BODY), "sha256")
    return msg, tag


def test_accept_once_then_replay():
    """the first delivery is accepted and remembered; the same bytes again are a replay"""
    seen = {}
    msg, tag = message()
    assert verify_envelope(msg, tag, T + 2, RING, seen, "orders") == "accepted"
    assert seen == {bytes(16): T + 300}
    assert verify_envelope(msg, tag, T + 42, RING, seen, "orders") == "replay"


def test_window_edges():
    """300 s behind and 30 s ahead are accepted; 301 s behind and 31 s ahead are not"""
    assert verify_envelope(*message(nonce=b"a" * 16), T + 300, RING, {}, "orders") == "accepted"
    assert verify_envelope(*message(nonce=b"b" * 16), T + 301, RING, {}, "orders") == "too-old"
    assert verify_envelope(*message(nonce=b"c" * 16, time=T + 30), T, RING, {}, "orders") == "accepted"
    assert verify_envelope(*message(nonce=b"d" * 16, time=T + 31), T, RING, {}, "orders") == "in-the-future"


def test_keys_named_by_the_message():
    """an unknown key ID, a retired key and a k1 tag relabelled as k2 are refused"""
    assert verify_envelope(*message(kid="k9", key=K1), T, RING, {}, "orders") == "unknown-key"
    assert verify_envelope(*message(kid="k1", key=K1, time=T + 86_401), T + 86_401, RING, {}, "orders") == "retired-key"
    assert verify_envelope(*message(kid="k1", key=K1, time=T + 3_600), T + 3_600, RING, {}, "orders") == "accepted"
    msg, k1_tag = message(kid="k1", key=K1)
    assert verify_envelope({**msg, "kid": "k2"}, k1_tag, T, RING, {}, "orders") == "bad-tag"


def test_bound_to_this_receiver():
    """a message tagged for orders is refused by refunds"""
    assert verify_envelope(*message(), T, RING, {}, "refunds") == "bad-tag"


def test_failures_leave_the_cache_alone():
    """forged, stale and early messages never add a nonce"""
    seen = {}
    msg, tag = message()
    assert verify_envelope(msg, bytes(32), T, RING, seen, "orders") == "bad-tag"
    assert verify_envelope(*message(time=T - 1_000), T, RING, seen, "orders") == "too-old"
    assert verify_envelope(*message(time=T + 1_000), T, RING, seen, "orders") == "in-the-future"
    assert seen == {}


def test_old_nonces_are_forgotten():
    """a nonce is dropped once its keep-until second has passed, so the cache does not grow without end"""
    seen = {}
    assert verify_envelope(*message(nonce=b"1" * 16), T, RING, seen, "orders") == "accepted"
    assert verify_envelope(*message(nonce=b"2" * 16, time=T + 400), T + 401, RING, seen, "orders") == "accepted"
    assert seen == {b"2" * 16: T + 700}
A hint

Look the key up with ring.get(msg["kid"]) so that an unknown ID cannot raise. Do the checks that need no memory first, the tag before the clock, and touch seen only at the end, once everything else has passed.

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.

Timestamp Converter Turn the Unix times of the envelope and the window into readable times, and back.

Finish with a self-check on the five ideas: it picks the idea you are least sure of, raises the level as you get answers right, and asks again about any you miss:

Practice that adapts to you

Practise this lesson

11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 11 Why must a receiver verify a webhook's tag over the raw request body?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: Because the sender tagged those exact bytes, and parsing then re-serialising can change them

    The tag is a function of bytes. Verify the bytes the sender tagged, the raw body, and only then parse them.

  2. Question 2 of 11 In the lesson's envelope every field but the time has a 4-byte length in front, and the time takes 8 bytes. How many bytes does an envelope with the label msc-envelope/v1, key ID k1, recipient refunds, a 16-byte nonce and a 2-byte body take?

    Type a number.

    A hint

    Add 4 + 15 for the label, 4 + 2 for the key ID, 8 for the time, then the nonce, the recipient and the body.

    Show the answer: Question 2 of 11

    Answer: 70 bytes

    Label 4 + 15 = 19, key ID 4 + 2 = 6, time 8, nonce 4 + 16 = 20, recipient 4 + 7 = 11 (refunds has 7 letters) and body 4 + 2 = 6, so the envelope takes 19 + 6 + 8 + 20 + 11 + 6 = 70 bytes. The lesson's own message is 106 bytes because its body is 39 bytes and its recipient 6.

  3. Question 3 of 11 What does the purpose label at the start of the envelope's tagged bytes prevent?

    Choose one answer.

    Show the answer: Question 3 of 11

    Answer: A tag made for one kind of message being accepted as another kind under the same key

    The label is domain separation: it makes the purpose part of what is tagged, so a value made for one use is never accepted for another, even when the same key serves both.

  4. Question 4 of 11 In the Python program, why does the message tagged for orders fail at refunds although both hold the key?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: Refunds rebuilds the tagged bytes with its own name as the recipient, so its recomputed tag differs

    Binding the recipient means each receiver verifies the message as addressed to itself. A message for another receiver then fails even under a shared key.

  5. Question 5 of 11 What is a replayed message?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: A valid, unchanged message delivered again, so its tag still verifies

    A replay sends a genuine message again. Its tag is valid, so the defence has to come from a signed time, a nonce cache or the event IDs the application records.

  6. Question 6 of 11 A provider retries a failed delivery with a new timestamp, a new nonce and a new tag. What stops your receiver from acting on the event twice?

    Choose one answer.

    Show the answer: Question 6 of 11

    Answer: Recording the event ID once the event is processed, and skipping events already recorded

    The cryptographic checks stop copies of a message; they cannot stop a second, genuine message about the same event. The application's record of processed event IDs does, which is why Stripe's documentation asks you to log them.

  7. Question 7 of 11 A team proposes a replay window of one day, "so slow deliveries never fail". What do you point out?

    Choose one answer.

    Show the answer: Question 7 of 11

    Answer: A copy stays acceptable for a day, and the nonce cache must remember a day of nonces; size the window for clock differences and delivery time instead

    The window trades availability against exposure: it must cover real clock differences and delays, and every extra minute keeps captured messages usable and the cache larger.

  8. Question 8 of 11 Why must the key ID be inside the tagged bytes rather than only beside them?

    Choose one answer.

    Show the answer: Question 8 of 11

    Answer: So that a tag made with one key cannot be presented as coming from another key ID

    Everything that decides how the receiver acts belongs inside the tagged bytes, the key ID included, so that it cannot be changed without breaking the tag.

  9. Question 9 of 11 A message names a key ID that the receiver's key ring does not have. What does the receiver do?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: Refuse it, without trying any key

    The receiver trusts only the keys it already holds. An unknown key ID is refused before any key is tried, as the program's last case shows.

  10. Question 10 of 11 When is HMAC with a shared key the right choice?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: Between one sender and one receiver that both keep the secret safe, when no third party must be convinced

    HMAC proves that one of the key holders made the message. With two parties who trust each other and keep the secret, that is enough.

  11. Question 11 of 11 You adopt RFC 9421 for webhook requests. Which of its signature parameters do the lesson's envelope time and key ID correspond to?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: created and keyid

    RFC 9421's created, expires, nonce, keyid and tag cover the time, the window, the nonce, the key ID and the purpose of this lesson's envelope, for HMAC as well as for signatures.

Interview questions

Warm-up (fresher to mid level): why does a signed webhook need a timestamp or an event ID when its signature already proves who sent it?

Because a signature or a MAC proves who made the bytes, not when or how often they arrive. A copy of a valid delivery, from a network retry or from someone who recorded it, carries the same valid tag, so a receiver that checks only the tag would act on it twice. A timestamp inside the signed bytes lets the receiver refuse copies older than a window, five minutes in Stripe’s libraries, and a nonce cache refuses repeats inside the window. Providers that sign only the body, as GitHub and Razorpay do, leave the event ID as the defence: record each event you have processed and skip it when it comes again. Retries are new messages about the same event, so the event ID is needed even with timestamps.

Warm-up (fresher to mid level): how does replaying a message differ from forging one, and what stops each?

A forgery is a message the attacker made, with a tag they had to invent: the MAC stops it, because without the key the tag is wrong, and a constant-time check keeps failed tries from teaching anything. A replay is a genuine message sent again unchanged, so its tag is valid and the MAC cannot tell it from the first copy. Stopping replays needs freshness inside the tagged bytes and memory on the receiver: a signed time checked against a window, a nonce remembered while its message could still pass the window, and the event IDs the application has already processed. A receiver needs both defences, because each one is blind to the other attack.

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Which bytes should a MAC cover, and how should extra tagged fields be joined?

    The request body exactly as it arrived, verified before any framework parses it: parsing JSON and writing it out again changes spacing, key order and the spelling of numbers. If a design tags structured data, both sides apply a canonical form such as RFC 8785. When the tag also covers a key ID, time or nonce, frame each field by its length so the bytes read back only one way.

  2. What belongs inside the tagged bytes of a message, and why?

    Every field that decides how the receiver acts: a version (refuse unknown versions, never fall back to a weaker one), a key ID (looked up in the receiver's own key ring), a purpose label for domain separation (so one kind of message's tag is never valid as another) and the recipient (a message for orders fails at refunds). Otherwise a message made for one use can be offered to another.

  3. Why can a valid tag arrive twice, and what makes a copy worthless?

    Networks repeat requests, providers retry and anyone who recorded a message can resend it, and no MAC alone tells a copy from the original. Three tools together: a signed time, refused outside a replay window around the receiver's clock; a random nonce kept in a replay cache while its message could still pass the window; and an event ID recorded once the application has acted.

  4. How are the replay window and the nonce cache related, and how big does the cache get?

    The window decides how long the cache must remember a nonce, so the cache holds only the messages of the last window: for 300 seconds at 200 messages a second that is 60,000 nonces, about 0.96 MB of 16-byte values. A replay older than the window is refused without reading the cache. Set the window for clock differences and delivery time, not for convenience; a tolerance of 0 turns the time check off.

  5. A provider's signature covers only the body, with no time. What defends against a replayed delivery?

    The event ID: record each event you have processed and skip it when it arrives again. GitHub's and Razorpay's signatures cover the body alone, and Razorpay sends a unique event-ID header. Retries are new messages about the same event, so event IDs are needed even with timestamps: Stripe signs a new timestamp for every retry.

  6. How do forging and replaying a message differ, and what stops each?

    A forgery is a message the attacker made with a tag they had to invent: the MAC stops it, and a constant-time check keeps failed tries from teaching anything. A replay is a genuine message sent again unchanged, so its tag is valid. It needs freshness inside the tagged bytes and memory on the receiver. A receiver needs both defences, since each is blind to the other attack.

  7. In what order do you rotate a MAC key without refusing messages in flight?

    Receivers learn the new key first, senders switch, both keys verify during an overlap, and receivers forget the old key last. Keep the old key at least as long as the sender retries old messages; Stripe lets two secrets stay active up to 24 hours. The message names a key ID inside its tagged bytes, and the receiver never uses a key, or an address to fetch one, taken from the message.

  8. When do you choose a signature instead of an HMAC for messages?

    With HMAC both sides hold the same key, so either could have made a tag: fine between two services that trust each other, weak when many receivers share a key, and no evidence for a third party. Use a signature (private key signs, public key verifies) when many parties verify, a third party must be convinced or a receiver cannot keep a secret. RFC 9421 supports both, hmac-sha256 and ed25519.

Key takeaways

  • Verify the raw bytes before parsing them, and frame every tagged field by its length so the bytes read back one way; changing one second of time or one space in the body changed the tag.
  • Put the version, key ID, purpose label and recipient inside the tagged bytes: the same tag failed at another receiver and as a sign-in link.
  • A valid tag can arrive twice, so a receiver checks a signed time against its window, remembers nonces while they could pass it, and records event IDs because retries are new messages.
  • Rotate keys in order: receivers add the new key, senders switch, both verify for longer than the sender retries, and only then is the old key dropped.
  • Use HMAC with a key per receiver between two parties who keep secrets, and a signature when many verify, a third party must be convinced or a receiver cannot keep a secret.

Terms in this lesson

key identifier (key ID)
A short public name for a key, such as k2, carried in a tagged or signed message so that the receiver knows which key of its own key ring to check with. RFC 9421 calls it keyid and JOSE calls it kid.
key rotation
Replacing a key with a new one, on a schedule or after a suspected leak, with an overlap in which receivers accept both, so that messages made with the old key and still in flight are not refused.
replay cache
A receiver's record of the nonces it has accepted, kept as long as their messages could still pass the replay window, so that a second copy of a valid message is refused.
replay window
The span around the receiver's clock in which a message's signed time must fall to be accepted, such as up to 300 seconds behind and 30 ahead. Older copies are refused even though their tags are valid.

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.