Stackvora.tech

Webhooks: Signing, Retries, and Replay Protection

Webhooks are a powerful mechanism for event-driven architectures, allowing systems to communicate asynchronously and react to changes in near real-time.…

Webhooks are a powerful mechanism for event-driven architectures, allowing systems to communicate asynchronously and react to changes in near real-time. However, implementing robust webhooks requires careful consideration of security, reliability, and idempotency. This article details best practices for securing webhook payloads, ensuring delivery despite transient failures, and preventing malicious replay attacks.

Payload Signing: Authenticity and Integrity

To assure consumers that a webhook payload genuinely originated from your service and has not been tampered with, implement payload signing using a Hash-based Message Authentication Code (HMAC). This method requires a shared secret between your service and each consumer.

HMAC Generation and Verification

When sending a webhook, your service calculates an HMAC of the payload using a unique secret key provided to the consumer. This signature is typically included in an HTTP header. Consumers then perform the same calculation with their secret and compare it to the received signature.

Sender-side (Your Service):

  • Choose a strong, cryptographically secure hash algorithm (e.g., SHA256, SHA512). SHA1 is generally deprecated for new implementations due to collision vulnerabilities.
  • Generate a unique secret for each consumer. These secrets should be long (e.g., 32-64 bytes), random, and stored securely. Rotate them periodically.
  • Concatenate a timestamp (e.g., Unix epoch in seconds or milliseconds) with the JSON payload. Signing the timestamp along with the payload is crucial for replay protection. The exact format of this concatenation should be clearly documented. A common approach is to prepend t=[timestamp], to the payload or include the timestamp as a top-level field in the JSON before serialization and signing.
  • Calculate the HMAC of the combined string (or body) using the consumer's secret.
  • Base64-encode the resulting HMAC digest.
  • Send the timestamp and the Base64-encoded signature in dedicated HTTP headers, for example, X-Webhook-Timestamp and X-Webhook-Signature. Alternatively, combine them in a single header like Stripe's Stripe-Signature: t=1678886400,v1=a1b2c3d4e5f6....

Example (Node.js for sender-side, using SHA256):

const crypto = require('crypto');

function signPayload(secret, payload) {
    const timestamp = Math.floor(Date.now() / 1000); // Unix timestamp in seconds
    const signedPayload = `t=${timestamp},${JSON.stringify(payload)}`;
    const hmac = crypto.createHmac('sha256', secret);
    hmac.update(signedPayload);
    const signature = hmac.digest('hex'); // Hex-encoded signature
    return {
        timestamp: timestamp,
        signature: `v1=${signature}`, // Prefix with version for future algorithm changes
        payload: signedPayload // This is what gets signed, not just the JSON part
    };
}

const consumerSecret = 'whsec_abcdefg1234567890abcdefg1234567890abcdefg';
const data = {
    "event_type": "order.created",
    "order_id": "ORD-2023-001",
    "amount": 100.50
};

const { timestamp, signature } = signPayload(consumerSecret, data);

// Headers to send:
// X-Webhook-Timestamp: 1678886400
// X-Webhook-Signature: v1=somehexencodedhmac
// (HTTP Body will be the JSON.stringify(data) but the signature is over 't=timestamp,{"event_type":...}')
// Or, if signing the raw body directly:
// const signedBody = JSON.stringify(data);
// const hmac = crypto.createHmac('sha256', secret).update(signedBody).digest('hex');
// This simplified signing does not include the timestamp in the signed material,
// requiring the timestamp to be validated separately for replay protection.
// For replay protection, signing the timestamp *with* the payload is critical.
// A common approach is to sign the raw body and send 'X-Webhook-Timestamp' separately,
// then the receiver validates the timestamp AND the signature.

// A more robust approach that explicitly signs the timestamp:
function signPayloadV2(secret, payload) {
    const timestamp = Math.floor(Date.now() / 1000);
    const body = JSON.stringify(payload);
    const signedContent = `${timestamp}.${body}`; // Example: timestamp concatenated with a dot and then body
    const hmac = crypto.createHmac('sha256', secret);
    hmac.update(signedContent);
    const signature = hmac.digest('hex');
    return {
        'X-Webhook-Timestamp': timestamp,
        'X-Webhook-Signature': `sha256=${signature}`, // Or 'v1='
        body: body // This is the actual HTTP request body
    };
}

const { 'X-Webhook-Timestamp': ts, 'X-Webhook-Signature': sig, body: reqBody } = signPayloadV2(consumerSecret, data);
console.log(`X-Webhook-Timestamp: ${ts}`);
console.log(`X-Webhook-Signature: ${sig}`);
console.log(`Request Body: ${reqBody}`);

Consumer-side:

  • Extract the timestamp and signature from the incoming HTTP headers.
  • Construct the exact string that was signed by your service (e.g., timestamp.body).
  • Calculate the HMAC of this string using the shared secret.
  • Compare the calculated HMAC with the received signature. If they match, the payload is authentic and untampered.

Replay Protection: Preventing Malicious Repetitions

Even with payload signing, a malicious actor could intercept a legitimate webhook and resend it multiple times. Replay protection mitigates this by using timestamps and nonce values.

Timestamp Validation

Include a timestamp in the signed payload (as discussed above) and in a separate HTTP header. Upon receiving a webhook, the consumer must:

  • Extract the timestamp from the header.
  • Compare it to the current server time. Reject payloads where the timestamp deviates significantly (e.g., older than 5 minutes or more than 5 minutes in the future). A typical window is 5 minutes for "too old" and perhaps 30 seconds for "too far in the future" to account for clock skew.
  • This window (e.g., 300 seconds) should be configurable and clearly documented.

Nonce (Optional, for higher assurance)

For even stronger replay protection, especially if a 5-minute window is considered too large, a nonce (number used once) can be incorporated. The sender includes a unique, unpredictable string (the nonce) in the signed payload. The receiver then stores all received nonces (e.g., in a Redis cache) for the duration of the replay window and rejects any webhook with a nonce that has been seen before. This adds complexity and state management to the consumer, so it's typically reserved for high-security scenarios.

Reliable Delivery: Retries with Exponential Backoff

Network glitches, temporary service outages, or slow processing on the consumer's side can lead to failed webhook deliveries. Implementing a robust retry mechanism is essential for reliability.

HTTP Response Codes

Your service should consider any HTTP 2xx status code from the consumer as a successful delivery. Any other status code (4xx, 5xx, or connection errors) should trigger a retry.

  • 200 OK: Success.
  • 201 Created: Success.
  • 202 Accepted: Success (indicates the request has been accepted for processing, but the processing is not yet complete).
  • 4xx Client Error: Typically indicates a permanent error (e.g., invalid payload, authentication failure). However, some 4xx codes (like 429 Too Many Requests or 408 Request Timeout) might warrant a retry. Carefully consider which 4xx codes should lead to retries versus permanent failures.
  • 5xx Server Error: Always retryable.

Recommended Consumer Response Time: Consumers should aim to respond with a 2xx status code within a strict timeout, typically 5 seconds. If processing takes longer, consumers should return a 202 Accepted and process the payload asynchronously.

Exponential Backoff Strategy

Retrying immediately or too frequently can overwhelm a struggling consumer. Exponential backoff increases the delay between retries over time, reducing load and giving the consumer more time to recover.

  • Initial Delay: Start with a short delay (e.g., 30 seconds, 1 minute).
  • Backoff Factor: Multiply the delay by a factor (e.g., 1.5x or 2x) for each subsequent retry.
  • Jitter: Add a small, random amount of time (jitter) to the delay to prevent a "thundering herd" problem if many webhooks fail simultaneously and attempt to retry at the exact same moment. E.g., delay = min(max_delay, initial_delay * backoff_factor^n + random_jitter).
  • Max Retries/Duration: Define a maximum number of retries or a total duration for retries. For instance, retry for up to 24-72 hours with a maximum of 10-15 attempts. After this, consider the delivery failed permanently.
  • Persistent Queue: Use a persistent message queue (e.g., Kafka, RabbitMQ, SQS) to store webhook events and manage retries. This ensures events are not lost if your webhook sender service restarts.

Example Retry Schedule (24-hour total duration, 1.5x backoff, 30s initial):

Attempt Delay (approx) Cumulative Time
1 (initial) 0s 0s
2 30s 30s
3 45s 1m 15s
4 1m 08s 2m 23s
5 1m 42s 4m 05s
6 2m 33s 6m 38s
7 3m 50s 10m 28s
8 5m 45s 16m 13s
9 8m 38s 24m 51s
10 12m 57s 37m 48s
11 19m 25s 57m 13s
12 29m 08s 1h 26m
13 43m 42s 2h 10m
14 1h 05m 3h 15m
15 1h 38m 4h 53m
16 2h 27m 7h 20m
17 3h 40m 11h 00m
18 5h 30m 16h 30m
19 8h 15m 24h 45m

Idempotency

Because webhooks can be retried, consumers must design their endpoints to be idempotent. This means processing the same webhook payload multiple times should have the same effect as processing it once. Often, this involves using a unique identifier (e.g., event_id, request_id) provided in the payload and checking if it has already been processed before taking action.

Monitoring and Visibility

Provide tools for consumers to monitor the status of their webhooks. This builds trust and reduces support overhead.

Delivery Log/Dashboard

Offer a dashboard or API endpoint where consumers can view the delivery status of recent webhooks. This should include:

  • Event ID.
  • Timestamp of the event.
  • Target URL.
  • Delivery attempts (timestamp of each attempt).
  • HTTP status code received for each attempt.
  • Response body (truncated or sanitized for security).
  • Current status (pending, delivered, failed).
  • Reason for failure (e.g., "Connection refused," "HTTP 500").

Alerting

Consider offering subscription to alerts for persistently failing webhooks (e.g., after 3 consecutive failures or after exhausting the retry schedule).

Common Pitfalls

  • Inconsistent Signing: Ensure the exact string signed by your service is precisely what the consumer expects to verify. Differences in newline characters, whitespace, or JSON key order can lead to signature mismatches. Document the signing process explicitly.
  • Clock Skew: Significant time differences between your server and the consumer's server can cause legitimate webhooks to fail timestamp validation. Allow a reasonable grace period (e.g., +/- 5 minutes). Advise consumers to use NTP.
  • Blocking Consumer Endpoints: Consumers failing to respond quickly (within the recommended 5 seconds) can tie up your webhook sender resources, potentially impacting other deliveries or even your core service.
  • Shared Secrets Exposed: If a consumer's secret is compromised, a malicious actor can forge webhooks. Provide a mechanism for consumers to easily rotate their secrets.
  • Hardcoded Retry Logic: Avoid embedding retry logic directly into application code without persistence. Use dedicated message queues or robust retry frameworks to handle crashes and ensure delivery attempts continue.
  • Lack of Idempotency: Without idempotent consumer endpoints, retried webhooks can lead to duplicate processing (e.g., charging a customer twice, creating duplicate records).
  • Insufficient Logging: Your service needs detailed logs for webhook delivery attempts, including the request sent, response received, and any errors, to aid in troubleshooting.

Back to the knowledge base · Ask the AI assistant