Webhook Signature Verification

After successfully registering and validating an endpoint, Anduin will start sending messages to your endpoints based on the types of events that you subscribed. Webhook signatures are your way to verity that webhook messages are sent by Anduin.

Why Verify webhook?

Webhooks offer functionality but also introduce security risks. Malicious actors can exploit them by sending fake requests that appear like normal HTTP data (often a POST request). This can lead to serious consequences for applications, causing disruptions and security breaches.

Anduin protects against these threats with a two-pronged approach:

  1. Webhook Signing: Every webhook and its data are signed with a unique key assigned to each endpoint. This signature acts like a digital fingerprint, ensuring the webhook originated from Anduin. Only webhooks with valid signatures are processed. Please refer to this for more details about webhook verification.
  2. Thwarting Replay Attacks: Even with valid signatures, attackers might try to "replay" intercepted webhooks. This payload, upon passing signature validation, will consequently be processed or acted upon. To prevent this, we timestamp each webhook. By rejecting webhooks with timestamps that differ from the current server by a duration (i.e. 5 minutes), it ensures only fresh webhooks are processed. However, for this to work effectively, your server's clock needs to be accurate. We recommend using Network Time Protocol (NTP) for time synchronization.

Webhook Verification with HMAC

We secure webhook communication using three key verification headers included in each webhook call:

  1. webhook-id: This unique identifier pinpoints a specific webhook message. It remains consistent only for retries of the same webhook due to a previous delivery issue.
  2. webhook-timestamp: This header transmits a timestamp indicating the exact time the webhook attempt occurred, formatted in seconds since the Unix epoch.
  3. webhook-signature: This header carries a Base64 encoded string containing a list of signatures, separated by spaces. It serves as a cryptographic fingerprint to authenticate the message's origin as Anduin.

Signed content

These three above elements, ID, timestamp, and signature, are joined together using periods (.) to form a single signed content

signedContent = "${webhook_id}.${webhook_timestamp}.${body}"

Important Note: The signature is extremely sensitive to any changes made to the content. Even the slightest modification to the body will result in a completely different signature, rendering the verification process unsuccessful. Therefore, it's critical to ensure the body remains unaltered before verification.

Validate the signature

Anduin uses Hash-based Message Authentication Code (HMAC) with SHA-256 to signs the webhook

Webhook secret

Whenever you interact with the webhooks, you can always retrieve the secret through our REST APIs

The secret must have the format of base64 encoded random bytes prefixed with whsec_ with size 24. A sample secret is whsec_BhHPJ2iLSdFHZKkaJu5SM4EWJFX+0jcP. If you do not specify the secret when creating a webhook, a random secret will be generated, then you can get or rotate that secret to verify.

Determine the signature

Your secret key used to sign is the base64 encoded portion of your signing secret (the part after whsec_). For the above sample secret, the key is BhHPJ2iLSdFHZKkaJu5SM4EWJFX+0jcP. After that, you can use the HMAC function with SHA-256 algorithm to generate a signature from the signed content.

The webhook-signature header contains a space-separated list of signatures and their corresponding version identifiers (e.g., v1,signature). Your generated signature must match one of the received signatures to verify that the webhook is authentic and originated from Anduin.

Sample scripts to verify HMAC

const crypto = require("crypto");

function verifyHmac(req) {
  const webhookId = req.header("webhook-id");
  const webhookTimestamp = req.header("webhook-timestamp");
  const webhookSignature = req.header("webhook-signature");
  const signedContent = `${webhookId}.${webhookTimestamp}.${JSON.stringify(req.body)}`;

  const secretBytes = Buffer.from(webhookSecret.split("_")[1], "base64");
  const derivedSignature = crypto
    .createHmac("sha256", secretBytes)
    .update(signedContent)
    .digest("base64");

  const expectedSignatures = webhookSignature
    .split(" ")
    .map((signature) => signature.split(",")[1]);

  if (expectedSignatures.includes(derivedSignature)) {
	  // Consume the event
  } else {
    throw new Error(
      `HMAC verified failed. Expected ${webhookSignature}, derived ${derivedSignature}`,
    );
  }
}

import hashlib
import hmac
import base64

def verify_hmac(req):
    webhook_id = req.headers.get('webhook-id')
    webhook_timestamp = req.headers.get('webhook-timestamp')
    webhook_signature = req.headers.get('webhook-signature')
    signed_content = f"{webhook_id}.{webhook_timestamp}.{request.get_data(as_text=True)}"
    
    secret_bytes = base64.b64decode(webhook_secret.split('_')[1])
    derived_signature = hmac.new(secret_bytes, signed_content.encode('utf-8'), hashlib.sha256).digest()
    derived_signature_base64 = base64.b64encode(derived_signature).decode('utf-8')

    expected_signatures = [sig.split(",")[1] for sig in webhook_signature.split(" ")]
    if derived_signature_base64 in expected_signatures:
        # Consume webhook event
    else:
        raise Exception(f"HMAC verification failed. Expected signature {webhook_signature}, derived signature {derived_signature_base64}")

It is recommended to use a constant-time string comparison method in order to prevent timing attacks.