Skip to content
bitzorcas
中EN

Guide

Webhook signing, replay defense, and idempotent consumption

Implement the current HMAC-SHA256 contract and add timestamp, event deduplication, rotation, and failure evidence.

Last updated

HTTPS protects the transport path but does not independently prove that a request came from the expected subscription. BitzOrcas signs the event identifier, Unix-millisecond timestamp, and SHA-256 payload hash with the subscription secret and HMAC-SHA256.

Current signature contract

Payload JSON text

SHA-256 payloadHash

eventId

Canonical input

timestamp ms

HMAC-SHA256 + secret

X-BitzOrcas-Signature

The canonical input is exactly three lines:

{eventId}
{unixTimestampMilliseconds}
{lowercaseSha256OfPayloadJson}

WebhookSignature.Compute uses a UTF-8 secret and emits lowercase hexadecimal. Verify recalculates and calls CryptographicOperations.FixedTimeEquals.

Delivery headers

The delivery service writes stable protocol headers:

POST /hooks/bitzorcas HTTP/1.1
Content-Type: application/json
X-BitzOrcas-Event-Id: evt_01J2M7X5
X-BitzOrcas-Event-Type: ticket.created
X-BitzOrcas-Timestamp: 1784073600000
X-BitzOrcas-Payload-Hash: 54a4...
X-BitzOrcas-Signature: 2f07...
{"type":"ticket.created","data":{"ticketId":"T-1001"}}

Consumers should treat header names case-insensitively but preserve event ID, timestamp text, and payload bytes exactly.

Receiver verification order

  1. Read event ID, timestamp, payload hash, and signature before business parsing.
  2. Reject timestamps outside the subscription’s allowed past/future window.
  3. Buffer the original request body and calculate its SHA-256.
  4. Compare the calculated hash to the supplied hash.
  5. Compute the three-line HMAC with the subscription secret.
  6. Compare in fixed time, then atomically claim the event ID.
  7. Parse and execute the business transaction only after verification.

Time-window and event-store checks are receiver responsibilities. WebhookSignature.Verify does not read the clock or save event IDs, so calling it alone does not prevent replay.

ASP.NET Core receiver

// Preserve the incoming body; reserializing JSON changes signed bytes.
request.EnableBuffering();
using var reader = new StreamReader(request.Body, leaveOpen: true);
var payloadJson = await reader.ReadToEndAsync(cancellationToken);
request.Body.Position = 0;
// Event ID, timestamp, and payload hash are all signed.
var payloadHash = WebhookSignature.ComputePayloadHash(payloadJson);
var valid = WebhookSignature.Verify(
secret, eventId, timestamp, payloadHash, suppliedSignature);
// Enforce freshness outside the cryptographic helper.
var age = clock.UtcNow - DateTimeOffset.FromUnixTimeMilliseconds(timestamp);
if (age.Duration() > TimeSpan.FromMinutes(5))
return Results.Unauthorized();
// The inbox claim must be atomic under concurrent deliveries.
if (!await inbox.TryBeginAsync(subscriptionId, eventId, cancellationToken))
return Results.Ok();

Return a 2xx idempotent success for an already completed event. Model in-progress and failed leases separately so a crashed consumer does not mark the event as permanently processed.

Preserve the raw payload

Whitespace, property order, Unicode escaping, and numeric formatting can change JSON bytes without changing its data model. The sender hashes its stored PayloadJson; a receiver that parses and serializes again may produce a different hash.

A proxy must not rewrite encoding or body content while claiming to preserve the original signature. If transformation is required, establish a new downstream signature boundary.

Store and rotate secrets

The subscription repository encrypts secret material at rest through IWebhookSecretCipher. The sender still recovers plaintext at runtime, so database encryption does not replace least privilege and log redaction.

Rotation sequence:

  1. create a new secret while retaining the old verifier;
  2. deploy dual verification and record the matched key version;
  3. switch the sender to the new secret;
  4. wait for retries and old queues to drain;
  5. revoke the old secret and prove old signatures fail;
  6. remove temporary dual-verification material.

Never place the secret in a URL, payload, error response, or delivery log. A stored signature is diagnostic evidence and needs restricted access and retention.

Failure and retry semantics

Missing headers, stale timestamps, and bad signatures should fail before business execution. Malformed business payloads may return a terminal 4xx. Temporary dependencies may return a retryable status according to the published delivery policy.

At-least-once delivery means one event ID can arrive more than once. Even if retries have a new delivery record or timestamp, the stable event ID remains the business idempotency key.

Node.js verification

// rawBody is the framework's byte buffer, not JSON.stringify(req.body).
const payloadHash = createHash("sha256").update(rawBody).digest("hex");
const canonical = `${eventId}\n${timestamp}\n${payloadHash}`;
const expected = createHmac("sha256", secret).update(canonical).digest("hex");
// timingSafeEqual requires equal-length inputs.
const valid = expected.length === supplied.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(supplied));

Contract tests

Publish a fixed vector containing secret, event ID, timestamp, payload, and expected signature. Cover:

  • deterministic lowercase hexadecimal output;
  • mutation of any signed field or the secret;
  • payload whitespace/property-order changes;
  • stale and future timestamps outside Verify;
  • concurrent duplicate event IDs causing one business effect;
  • old/new secret overlap and old-key revocation;
  • no secret or sensitive payload in logs and ProblemDetails.

Current boundary

WebhookSignature implements the cryptographic primitive. WebhookDeliveryService calculates the hash/signature and sends all five protocol headers. The delivery aggregate records event and signature facts. A generic receiver middleware, a universal five-minute policy, and an external consumer inbox cannot be completed by the sender module.

Treat “signed delivery shipped” and “consumer replay defense proven” as separate GA claims. Commercial delivery should publish vectors, receiver samples, and Consumer Contract Tests rather than only Compute unit tests.

See also

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%