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.
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 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
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
v1to 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
ordersfails atrefunds.
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: 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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The 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
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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 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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The 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
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.
- 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.
- 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.
- 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.
- 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.
- 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:
// 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
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:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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
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.
- Frame and tag an envelope so its fields read one way To do
- 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
ValueErrorfor a nonce that is not exactly 16 bytes and for a time that is negative or does not fit in 8 bytes. sign_envelopereturns the 32-byte HMAC-SHA-256 of those bytes underkey.
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").
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · 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:
msgholdskid,time,nonceandbody.ringmaps key IDs to{"key": bytes, "accept_until": seconds};seenmaps nonces to the second until which they are kept.nowis the receiver's clock in seconds.- In this order:
"unknown-key"for a key ID the ring does not have;"retired-key"whennowis past itsaccept_until;"bad-tag"when the tag of the envelope rebuilt withmeas recipient differs fromtag, compared withhmac.compare_digest;"in-the-future"when the time is more than 30 seconds ahead ofnow;"too-old"when it is more than 300 seconds behind. - Then forget every nonce in
seenwhose keep-until second is beforenow, return"replay"for a nonce that is still there, and otherwise remember the nonce untiltime + 300and 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
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.
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
- RFC 9421: HTTP Message Signatures (Internet Engineering Task Force (IETF))
- RFC 8785: JSON Canonicalization Scheme (JCS) (Internet Engineering Task Force (IETF))
- RFC 2104: HMAC, Keyed-Hashing for Message Authentication (Internet Engineering Task Force (IETF))
- Receive Stripe events in your webhook endpoint (signatures, replay attacks, rolling secrets) (Stripe)
- Validate and Test Webhooks (Razorpay)
- Validating webhook deliveries (GitHub)
- Python 3.14 documentation: hmac, keyed-hashing for message authentication (Python Software Foundation)
- Node.js 24 documentation: Crypto (createHmac and timingSafeEqual) (OpenJS Foundation (Node.js))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress