Why webhook security needs more than a shared secret

Many webhook providers sign requests with an HMAC over the raw request body and selected headers. That is a good start, but it is not enough on its own.

A secure webhook endpoint should defend against:

  • Forgery: an attacker sends a request that mimics the provider.
  • Replay: a captured valid request is resent later.
  • Body tampering: the payload is modified after signing.
  • Implementation drift: parsing or re-serializing the body before verification changes the bytes being checked.
  • Duplicate side effects: the same event is processed multiple times.

The key principle is simple: verify the exact bytes received, then process the event once.


A secure verification flow

A good webhook pipeline usually looks like this:

  1. Read the raw request body exactly once.
  2. Extract the signature and timestamp headers.
  3. Recompute the expected signature from the raw body and a shared secret.
  4. Reject requests with stale timestamps.
  5. Compare signatures using constant-time equality.
  6. Parse the body only after verification succeeds.
  7. Use an idempotency key or event ID to prevent duplicate processing.

The order matters. If you deserialize first, you may accidentally accept malformed data or alter the body representation before verification.


Example: verifying an HMAC-signed webhook in Rust

The following example uses axum for HTTP handling and hmac plus sha2 for signature verification. The same pattern applies to other frameworks.

use axum::{
    body::Bytes,
    extract::State,
    http::{HeaderMap, StatusCode},
    response::IntoResponse,
};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use std::time::{SystemTime, UNIX_EPOCH};

type HmacSha256 = Hmac<Sha256>;

#[derive(Clone)]
struct AppState {
    webhook_secret: Vec<u8>,
    max_skew_seconds: i64,
}

async fn webhook_handler(
    State(state): State<AppState>,
    headers: HeaderMap,
    body: Bytes,
) -> impl IntoResponse {
    let signature = match headers.get("X-Signature") {
        Some(v) => match v.to_str() {
            Ok(s) => s,
            Err(_) => return StatusCode::UNAUTHORIZED,
        },
        None => return StatusCode::UNAUTHORIZED,
    };

    let timestamp = match headers.get("X-Timestamp") {
        Some(v) => match v.to_str() {
            Ok(s) => s,
            Err(_) => return StatusCode::UNAUTHORIZED,
        },
        None => return StatusCode::UNAUTHORIZED,
    };

    let timestamp: i64 = match timestamp.parse() {
        Ok(ts) => ts,
        Err(_) => return StatusCode::UNAUTHORIZED,
    };

    let now = match SystemTime::now().duration_since(UNIX_EPOCH) {
        Ok(d) => d.as_secs() as i64,
        Err(_) => return StatusCode::INTERNAL_SERVER_ERROR,
    };

    if (now - timestamp).abs() > state.max_skew_seconds {
        return StatusCode::UNAUTHORIZED;
    }

    let expected = match compute_signature(&state.webhook_secret, timestamp, &body) {
        Ok(sig) => sig,
        Err(_) => return StatusCode::INTERNAL_SERVER_ERROR,
    };

    let provided = match hex::decode(signature) {
        Ok(bytes) => bytes,
        Err(_) => return StatusCode::UNAUTHORIZED,
    };

    if provided.len() != expected.len() || provided.ct_eq(&expected).unwrap_u8() != 1 {
        return StatusCode::UNAUTHORIZED;
    }

    // Parse and process only after verification succeeds.
    // Example: let event: WebhookEvent = serde_json::from_slice(&body)?;
    StatusCode::OK
}

fn compute_signature(secret: &[u8], timestamp: i64, body: &[u8]) -> Result<Vec<u8>, hmac::digest::InvalidLength> {
    let mut mac = HmacSha256::new_from_slice(secret)?;
    mac.update(timestamp.to_string().as_bytes());
    mac.update(b".");
    mac.update(body);
    Ok(mac.finalize().into_bytes().to_vec())
}

Why this pattern is safe

  • It verifies the raw bytes from the request body.
  • It binds the signature to a timestamp, reducing replay risk.
  • It uses constant-time comparison to avoid leaking information through timing differences.
  • It avoids parsing the payload before authentication.

Choosing the right verification inputs

Webhook providers differ in how they define the signed message. Some sign only the body, while others include a timestamp, request ID, or a version prefix.

Input patternSecurity benefitTypical use
Body onlySimple, but replayableBasic integrations
Timestamp + bodyMitigates replay attacksCommon provider pattern
Timestamp + request ID + bodyStronger replay resistanceHigher-risk workflows
Versioned signing stringAllows future format changesLong-lived APIs

If your provider specifies a signing string, follow it exactly. Do not invent your own canonicalization rules unless you control both ends of the protocol.

A common mistake is to parse JSON and then re-serialize it before verification. That can change whitespace, key order, or escaping, which breaks signature validation and may create false positives if you accidentally sign a different representation than the sender.


Enforcing freshness to block replay attacks

A valid signature does not guarantee a request is recent. An attacker who captures traffic from logs, proxies, or compromised clients may replay the same request later.

Use a timestamp header and reject requests outside a narrow window, such as 5 minutes. The acceptable skew depends on your infrastructure and clock synchronization.

Practical guidance

  • Keep the window as small as operationally feasible.
  • Use UTC epoch seconds or another unambiguous format.
  • Monitor clock drift on your servers.
  • Return a generic unauthorized response for stale requests.

If the provider includes a unique event ID, store it in a durable deduplication table and reject duplicates even if the timestamp is still valid.


Making webhook processing idempotent

Replay protection at the transport layer is not enough. Even legitimate retries from the provider can cause duplicate deliveries. Your application should treat webhook processing as idempotent.

A reliable pattern is:

  1. Extract the event ID from the verified payload.
  2. Insert the event ID into a database table with a unique constraint.
  3. If the insert succeeds, process the event.
  4. If the insert fails because the ID already exists, return success without repeating side effects.

This approach protects you from:

  • provider retries,
  • network timeouts,
  • worker crashes after partial processing,
  • duplicate manual submissions during testing.

Example deduplication table

CREATE TABLE processed_webhooks (
    event_id TEXT PRIMARY KEY,
    processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

In Rust, you would insert the event ID in the same transaction as any state changes that must happen exactly once.


Avoiding subtle implementation mistakes

Webhook security failures often come from small details rather than missing cryptography.

1. Reading the body twice

Many frameworks consume the request body stream once. If you try to verify after deserializing, you may no longer have the original bytes. Always buffer the body first, then verify, then parse.

2. Normalizing headers incorrectly

Header names are case-insensitive, but values are not. Trim only what the provider allows. Do not “clean up” the signature string by removing characters or changing encoding.

3. Using string comparison for signatures

Never compare signatures with == on strings if the comparison may be observable. Use constant-time equality on decoded bytes.

4. Accepting weak algorithms

If the provider supports multiple algorithms, prefer the strongest one available and reject deprecated options. For example, avoid SHA-1 if SHA-256 is supported.

5. Logging sensitive request data

Webhook bodies may contain personal data, account identifiers, or tokens. Log only the minimum necessary metadata, such as event ID, provider name, and verification result.


Structuring your Rust code for maintainability

A secure webhook implementation is easier to maintain when verification is isolated from business logic.

A useful structure is:

  • verify_request(...) -> Result<VerifiedWebhook, WebhookError>
  • parse_event(...) -> Result<Event, WebhookError>
  • handle_event(...) -> Result<(), DomainError>

This separation makes it clear that no domain action can happen before verification succeeds.

Suggested error handling strategy

Return a small set of externally visible errors:

  • 401 Unauthorized for missing, invalid, or stale signatures
  • 400 Bad Request for malformed payloads after verification
  • 500 Internal Server Error for infrastructure failures

Avoid returning detailed cryptographic failure reasons to the caller. Those details are more useful in internal metrics and logs than in responses.


Testing your webhook defenses

Security-sensitive code needs tests that reflect real attack scenarios, not just the happy path.

Recommended test cases

  • valid signature and fresh timestamp
  • invalid signature with correct body
  • valid signature with modified body
  • stale timestamp
  • malformed signature encoding
  • duplicate event ID
  • empty body
  • body with unusual Unicode or binary bytes

If possible, store a few real signed payloads from your provider in test fixtures. That helps catch mistakes in canonicalization and header parsing.

Example test idea

  • Generate a signature over a known body and timestamp.
  • Send the same body with one byte changed.
  • Confirm the request is rejected.
  • Replay the original request after the timestamp window expires.
  • Confirm the request is rejected again.

Operational best practices

Security is not only code. Your deployment and runtime settings matter too.

  • Use HTTPS only between the provider and your service.
  • Restrict inbound traffic if the provider publishes source IP ranges.
  • Set request body limits to prevent resource exhaustion.
  • Rotate webhook secrets periodically and support overlapping keys during migration.
  • Monitor verification failures for spikes that may indicate abuse or integration drift.
  • Store secrets securely in environment variables or a secret manager, not in source control.

A webhook endpoint should be boring in production: reject bad requests quickly, process valid ones exactly once, and expose minimal information.


A concise checklist

Before shipping a webhook endpoint, confirm that you have:

  • verified the raw body bytes
  • validated the signature with a strong algorithm
  • checked a freshness timestamp
  • used constant-time comparison
  • parsed the payload only after verification
  • deduplicated event IDs
  • limited request size
  • avoided sensitive logging
  • tested replay and tampering cases

If all of those are in place, your webhook handler is far less likely to become an easy entry point for attackers.

Learn more with useful resources