skip to content

Your receiver dedupes on an unsigned request-id header while the signature covers only the body — what replay is still possible?

level: middleimportance: should knowfreq 42%

answer

  1. Draw a line around the signed bytes
  2. Two checks reading different bytes
  3. The key the sender can freely change
  4. Bind the audience, not just the amount
  5. One instruction, one identifier, many attempts

basics

~20 s

The header sits outside the signed bytes, so whoever holds the message changes that value and resends the identical body. The deduplication key never matches, the signature still verifies, and the instruction executes again. The key must live inside the signed content.

solid answer

~50 s

Deduplication is only as strong as the binding between the key and the content it protects. If the signature covers the JSON body and the deduplication key rides in a transport header, the key is attacker-controlled: anyone holding a copy — typically an engineer at the sending partner — emits the same body with a fresh header value, the receiver's remembered-identifier set misses, verification succeeds, and the payment happens twice. Moving the identifier inside the signed content fixes it, because now changing the identifier invalidates the signature and reusing it hits memory. The same argument applies to every field the freshness decision depends on: the timestamp, the counter, and the intended recipient. If the recipient is not inside the signed content, a message meant for one receiver can be presented to a second one that trusts the same signing key, and both act on a single authorised instruction.

code

text · 12 lines
text
POST /v1/payments                     <- envelope: NOT signed
X-Request-Id: 8f1c...a92               <- receiver dedupes on THIS value
X-Signature: base64(sig over body)     <- covers the JSON only

{                                      <- signed bytes begin
  "from":     "partner-4471",
  "to":       "acct-90233",
  "amount":   "250000.00",
  "currency": "EUR"
}                                      <- no id, no timestamp, no audience

# Replay: same body, same signature, new X-Request-Id -> both checks pass

go deeper

for a junior

Remember that a signature only covers the bytes it was computed over. Anything outside that region — a transport header, a query parameter — can be changed freely by whoever sends the request.

for a middle

Trace the two checks and show they read different bytes: verification reads the body, deduplication reads the header. Then state the rule — every input to the freshness decision belongs inside the signed content.

for a senior

Review a message envelope and name all three bindings that must be inside the signature: the deduplication key, the freshness input, and the intended recipient. Explain the distinct replay each missing one enables.

for a principal

Set the message-envelope contract once across partner integrations rather than per service, so that the audience and identifier fields are mandatory by format, and a new integration cannot ship a receiver that dedupes on something the sender controls.

## The defect in one line *Whatever the freshness decision reads must be inside what the signature covers.* A deduplication key outside the signed region is a value the replayer chooses, and a value the replayer chooses can never make the receiver refuse them. ## Walking the attack The receiver's logic looks correct in review: it keeps a set of recently seen request identifiers, refuses a repeat, and verifies the signature before acting. Both checks exist. But they read different bytes. A party holding a copy of the message — and in a partner integration the sender's own operations tooling holds every copy — does the following: keeps the body and its signature exactly as they are, generates a new value for the request-id header, and sends. Verification is computed over the body, so it succeeds. The deduplication lookup is computed over the header, so it misses. Both gates open. The instruction executes a second time, and there is nothing anomalous about the traffic at all: valid key, valid content, valid connection, a request identifier nobody has seen before because it was invented one second ago. Nothing was forged, and nothing was decrypted. The signature was never attacked — it was simply bypassed by moving the part of the message it did not cover. ## The general rule: bind everything the decision depends on A message that must be safe to receive exactly once carries, inside the signed content: - **A unique identifier** for the logical instruction — the value the receiver's memory is keyed on. - **A timestamp or counter** — whichever the receiver enforces, so that age or ordering cannot be edited. - **The intended recipient** — an audience or endpoint identifier. - **The material terms**, obviously: amount, account, action. The recipient field is the one most often missed and it produces a distinct replay. If a signing key is trusted by more than one system — a production and a staging receiver, two regional services, a settlement service and a reporting service — an instruction authorised for one can be presented to the other, and the second receiver's memory contains nothing about it. It refuses nothing because it has never seen this message; it was simply never the addressee. Binding the audience inside the signed content turns that into a verification failure rather than a second execution. The same field is what makes traffic captured from one environment useless in another. ## Why the identifier must identify the instruction, not the transmission There is a second, quieter failure. Suppose the identifier *is* inside the signed content, but the sender generates a fresh one on every transmission attempt. Now the receiver's memory works perfectly and protects nothing, because the sender's own legitimate retry produces a message the receiver has never seen and duly executes. The identifier must be derived from, or fixed to, the logical instruction — one payment, one identifier, however many times it is transmitted. That property is what lets the receiver's memory distinguish *this instruction again* from *a different instruction that happens to look similar*, and it is also what makes a legitimate retry safe rather than doubling the payment. Note the boundary here: how a client chooses and re-uses that value across retries, and the HTTP semantics of retrying safely, is an API design subject in its own right. What belongs to the replay question is narrower and sharper — the value the receiver refuses on must be under the signature, and it must name the instruction rather than the attempt. ## Reviewing an envelope quickly Given a message format, the review is three questions: 1. Draw a line around the bytes the signature covers. Is the deduplication key inside it? 2. Is the freshness input — timestamp or counter — inside it? 3. Is the intended receiver inside it? Any "no" is a replay, and each one has a different flavour: a mutable key means unlimited replay to the same receiver; a mutable timestamp means unlimited lifetime; a missing audience means replay to a *different* receiver that trusts the same key. ## The counter-argument you will hear "But the header is inside TLS, so nobody can change it." That answer confuses the channel with the party. The header is protected against modification *by a third party in transit*; it is entirely under the control of whoever composes the request — and the party replaying already composes requests, legitimately, with a valid client certificate and a valid signature over a body they already hold. Confidentiality of the path does not constrain the endpoints.

  • What extra replay becomes possible if the intended recipient is not inside the signed content?
    Cross-receiver replay. Any system that trusts the same signing key will accept an instruction that was authorised for a different one — a second regional service, a settlement service, or a production endpoint fed traffic captured from elsewhere. Its memory holds nothing about that message because it was never the addressee, so it executes. Binding an audience identifier inside the signed content converts that into a verification failure.
  • The identifier is inside the signed body, but the sender generates a new one on every attempt. Is the receiver protected?
    No. The memory works and protects nothing, because each transmission of the same logical instruction carries a value the receiver has never seen. The identifier has to name the instruction, not the attempt — one payment, one identifier, however many times it is sent. Otherwise the sender's own retry is indistinguishable from a deliberate second execution, and both go through.
  • Someone argues the header is safe because it travels inside TLS. What is wrong with that?
    It confuses the channel with the party. TLS stops a third party on the path from modifying the header; it places no constraint at all on whoever composes the request. The party replaying is an endpoint, not an interceptor — it holds a valid client credential and a body it legitimately possesses, so composing a fresh header value costs it nothing.

saying these in an interview costs you the question

  • Says TLS protects the header so it cannot be changed
  • Believes any unique-looking identifier is enough for deduplication
  • Never checks which bytes the signature actually covers
  • Omits the intended recipient from the signed content
  • Generates a fresh identifier per transmission attempt

context