Module 7 · Labs & experiments
Lab — prevent replay
A guided, self-contained lab on synthetic data. A signature proves authenticity, but a captured valid message can be resent unless you bind it to a single use. You add a nonce (seen-once), an expiry (time-bound), and an audience (this-verifier-only), then demonstrate that the second presentation of a genuinely signed message is rejected. Freshness is a separate property from authenticity.
Learning objective
Harden a signed message against replay with three bindings — a one-time nonce, an expiry, and an audience — then replay the exact same valid message and watch the verifier accept it once and reject it the second time.
- Track
- Engineering
- Module
- 7 · Labs & experiments
- Prerequisites
- Digital signatures · Message integrity, nonces and replay · Node.js 16+ (built-in crypto)
Lab goal
This teaching exercise, offline and synthetic, shows why a valid signature is not enough. An attacker who captures a correctly signed "do X" message can replay it to make X happen again. You will bind the message to a single use and a single context, so replaying the identical signed bytes fails the second time even though the signature still verifies.
Replay defense is about freshness and context, not authenticity. The signature keeps verifying on a replay — what changes is that the verifier now also checks "have I seen this nonce?", "is it still in date?" and "was it meant for me?".
The three bindings
- Nonce
- A unique, single-use value included in the signed message. The verifier remembers nonces it has accepted and rejects any repeat.
- Expiry (exp)
- A timestamp after which the message is no longer valid, bounding the window in which a captured message is useful.
- Audience (aud)
- An identifier of the intended verifier, signed in. A verifier rejects a message whose audience is not itself, preventing a message meant for service X being replayed at service Y.
Step by step
- 1Build a message containing action, a fresh random nonce, an expiry, and the intended audience.
- 2Sign the canonical bytes with the sender private key.
- 3On the verifier: check the signature, then check audience matches, then check not expired, then check the nonce has not been seen; if all pass, record the nonce as used.
- 4Present the message once: it is accepted.
- 5Replay the exact same message and signature: the signature still verifies, but the nonce is already recorded, so the verifier rejects it.
lab6.js — synthetic replay prevention (Node.js crypto)const crypto = require('crypto'); const canonical = (o) => Buffer.from(JSON.stringify(o, Object.keys(o).sort())); const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519'); const MY_AUDIENCE = 'service:vault-lab'; const seenNonces = new Set(); // verifier's replay cache function send(action) { const msg = { action, aud: MY_AUDIENCE, nonce: crypto.randomBytes(16).toString('hex'), exp: Math.floor(Date.now() / 1000) + 30, // valid 30s }; const sig = crypto.sign(null, canonical(msg), privateKey); return { msg, sig }; } function verify({ msg, sig }, now = Math.floor(Date.now() / 1000)) { if (!crypto.verify(null, canonical(msg), publicKey, sig)) return 'reject: bad signature'; if (msg.aud !== MY_AUDIENCE) return 'reject: wrong audience'; if (msg.exp < now) return 'reject: expired'; if (seenNonces.has(msg.nonce)) return 'reject: nonce replay'; seenNonces.add(msg.nonce); return 'accept'; } const envelope = send('rotate-key'); console.log('first presentation:', verify(envelope)); // accept console.log('replay same message:', verify(envelope)); // reject: nonce replay // Same bytes aimed at a different service: const other = { msg: { ...envelope.msg, aud: 'service:other' }, sig: envelope.sig }; console.log('wrong audience :', verify(other)); // reject: bad signature (aud was signed)
Expected result
expected console outputfirst presentation: accept replay same message: reject: nonce replay wrong audience : reject: bad signature
The first presentation passes every gate and the nonce is recorded. The replay carries a genuinely valid signature, yet it is rejected because the nonce is already in the seen set. The last case changes the audience field after signing, so the signature no longer matches the altered bytes and fails at the signature check — showing that audience is protected precisely because it is inside the signed message.
If you set exp into the past (or wait past the window), you would instead see "reject: expired" before the nonce is even consulted. Order the checks so the cheapest and most decisive run first.
Common mistakes
- ▸Putting the nonce/expiry/audience outside the signature. If they are not signed, an attacker edits them freely; they must be part of the canonical bytes.
- ▸An unbounded nonce cache that grows forever. In practice pair the nonce with the expiry and evict expired nonces, so the cache only needs to cover the validity window.
- ▸Skipping the audience binding: a message legitimately signed for service X can be replayed to service Y that trusts the same key.
- ▸Relying on expiry alone. A short window still allows replay inside it; the nonce is what makes a message truly single-use.
- ▸Treating "accept" as "the action is correct". Freshness and authenticity still say nothing about whether the action is safe or wise.
Exercise
The nonce cache in the lab never forgets, so it grows without bound. Design a bounded replacement that is still safe against replay, and state the one assumption your design depends on.
Expected direction: Store each accepted nonce together with the message expiry, and periodically evict entries whose exp has passed; a message can then only be replayed within its own validity window, which the cache always still covers, so a replay either hits a live cached nonce (rejected) or is already expired (rejected by the exp check). The design depends on the assumption that verifier clocks are reasonably synchronized / monotonic within the allowed skew, so that "expired" is judged consistently; an attacker who can move the clock backward could otherwise revive an evicted nonce.
Check your understanding
- Why does a valid signature not prevent replay on its own?
- A signature proves the bytes are authentic and unaltered, but the same valid bytes can be captured and resent; preventing that needs freshness and context bindings (nonce, expiry, audience).
- Why must the nonce, expiry and audience be inside the signed message?
- So they are covered by integrity: if they sat outside the signature an attacker could change them (e.g. redirect the audience or extend the expiry) without breaking verification.
- What does the audience binding specifically stop?
- A message legitimately signed for one verifier being replayed at a different verifier that happens to trust the same signing key.
References
- [1]RFC 8032 — EdDSA / Ed25519
- [2]RFC 7519 — JSON Web Token (exp, aud, jti claims)
- [3]NIST SP 800-63B — Digital Identity Guidelines (replay resistance)
References point to public, well-known technical sources for further study.
Related articles
