> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ditto.live/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Security

> Secure your authentication webhooks with signature validation.

## Overview

[Authentication webhooks](/sdk/latest/auth-and-authorization/cloud-authentication#server) are HTTP endpoints you implement to control user access in Ditto databases.
When a client device attempts to authenticate, Ditto forwards the authentication request to your webhook, which validates the user's credentials and returns their permissions.

To verify the request is legitimately coming from a Ditto system, Ditto may optionally include a `ditto-signature` authentication header with every webhook request.
This header contains everything your webhook server needs to independently verify that the request is both legitimate (*truly from Ditto*) and fresh (*not a replay attack*).
See the [Signature Algorithm](#signature-algorithm) section below for details on how signatures are computed.

Your webhook server uses the information in this header to perform verification.
By recomputing the signature using your shared secret and comparing it with the provided signatures, your server can confirm:

* **Authenticity**: The request genuinely originates from Ditto, not an impersonator
* **Integrity**: The payload hasn't been modified since Ditto signed it
* **Freshness**: The timestamp is recent (typically within 5 minutes), preventing replay attacks

This verification relies on a **shared secret** that only you and Ditto know. Ditto uses this secret to sign requests, and your webhook server uses the same secret to verify that incoming requests are authentic.
Only after signature verification passes should your webhook validate user credentials and return permissions.
The authentication flow is shown below.

```mermaid theme={null}
sequenceDiagram
    participant Client as Client Device<br/>(Ditto SDK)
    participant Cloud as Ditto Cloud<br/>(BigPeer)
    participant Webhook as Your Authentication<br/>Webhook

    Note over Client: 1. User logs in with credentials<br/>ditto.auth.loginWithToken(token, provider)

    Client->>Cloud: 2. Authentication request

    Note over Cloud: 3. Prepare webhook request
    Cloud->>Webhook: 4. HTTP POST<br/>Header: ditto-signature<br/>Body: {databaseID, provider, token}

    Note over Webhook: 5. Validate request<br/>✓ Verify ditto-signature<br/>✓ Check timestamp freshness<br/>✓ Validate user credentials<br/>✓ Return user permissions

    Webhook->>Cloud: 6. Authentication response<br/>{authenticated: true, userID, permissions}

    Note over Cloud: 7. Issues JWT to client
    Cloud->>Client: 8. Authenticated session established

    Note over Client: User can now sync data<br/>according to permissions
```

## Signature verification

Webhook signature is computed using HMAC-SHA256.
Ditto concatenates the timestamp with the raw request body, hashes this payload using a base64-decoded shared secret key, and encodes the output as hexadecimal.
The following table breaks down each component of this process:

| Component       | Description                                                                              |
| --------------- | ---------------------------------------------------------------------------------------- |
| Secret          | 128 bytes (1024 bits) of cryptographically secure random data, base64-encoded (RFC 4648) |
| Payload         | Timestamp concatenated with raw request body: `<timestamp>.<raw_body>`                   |
| Algorithm       | HMAC-SHA256 hash of the payload using the decoded secret                                 |
| Output Encoding | Hexadecimal representation of the HMAC output                                            |

The `ditto-signature` header has this format:

```
ditto-signature: t=<timestamp>,v1=<signature1>,v1=<signature2>
```

where:

* `t=<timestamp>` - Unix timestamp in UTC when the request was signed (seconds since epoch)
* `v1=<signature>` - HMAC-SHA256 signature(s) in hexadecimal format

An example header:

```
ditto-signature: t=1764758735,v1=bb741e722b8405c09eb8d3a8824be625dcea15c7c783a625de7fc40926a43125
```

<Info>
  During secret rotation, Ditto can include multiple `v1=` signatures in the header, one for each active secret.
  This allows zero-downtime rotation: your endpoint can validate with either the old or new secret during the transition period.
</Info>

The examples below include a language-agnostic pseudocode algorithm followed by implementations in Node.js, Python, Rust, and Go.
You can adapt these to any language or framework for your server-side webhook endpoint.

<CodeGroup>
  ```go Pseudocode theme={null}
  VERIFY_WEBHOOK_SIGNATURE(request):
      // 1. Extract and parse signature header: "t=1234567890,v1=abc123,v1=def456"
      header = request.headers["ditto-signature"]
      if header is missing:
          reject request (missing signature)

      parts = header.split(",")
      timestamp = extract value after "t=" from parts[0]
      provided_signatures = extract values after "v1=" from parts[1..]

      // 2. Validate timestamp freshness (replay protection)
      current_time = current UTC timestamp in seconds
      age = |current_time - timestamp|
      if age > 300 seconds:
          reject request (too old, possible replay attack)

      // 3. Reconstruct signed payload
      raw_body = request.raw_body_bytes()  // CRITICAL: raw bytes, not parsed JSON
      payload = timestamp + "." + raw_body

      // 4. Compute expected signatures for active secrets
      expected_signatures = []
      for each secret_base64 in YOUR_ACTIVE_SECRETS:
          secret_bytes = base64_decode(secret_base64)
          hmac = HMAC-SHA256(secret_bytes, payload)
          expected_signatures.append(hex_encode(hmac))

      // 5. Verify at least one signature matches
      for each expected in expected_signatures:
          for each provided in provided_signatures:
              if expected == provided:
                  return accept request (authorized)

      return reject request (invalid signature)
  ```

  ```js Node.js theme={null}
  const crypto = require('crypto');

  /**
   * Verifies webhook signature from Ditto
   * @param {string} signatureHeader - The "ditto-signature" header value
   * @param {string} rawBody - Raw request body as string (NOT parsed JSON)
   * @param {string[]} secrets - Array of base64-encoded webhook secrets
   * @returns {boolean} - True if signature is valid
   */
  function verifyWebhookSignature(signatureHeader, rawBody, secrets) {
    if (!signatureHeader) {
      throw new Error('Missing ditto-signature header');
    }

    // 1. Parse header: "t=1234567890,v1=abc123,v1=def456"
    const parts = signatureHeader.split(',');
    const timestamp = parts[0].split('=')[1];
    const providedSignatures = parts.slice(1).map(p => p.split('=')[1]);

    // 2. Validate timestamp (5-minute window)
    const requestTime = parseInt(timestamp, 10);
    const currentTime = Math.floor(Date.now() / 1000);
    const age = Math.abs(currentTime - requestTime);

    if (age > 300) {
      throw new Error('Request too old');
    }

    // 3. Reconstruct payload: "<timestamp>.<raw_body>"
    const payload = `${timestamp}.${rawBody}`;

    // 4. Compute expected signatures for all active secrets
    const expectedSignatures = secrets.map(secret => {
      const secretBytes = Buffer.from(secret, 'base64');
      const hmac = crypto.createHmac('sha256', secretBytes);
      hmac.update(payload);
      return hmac.digest('hex');
    });

    // 5. Verify at least one signature matches (constant-time comparison)
    for (const expected of expectedSignatures) {
      for (const provided of providedSignatures) {
        if (crypto.timingSafeEqual(
          Buffer.from(expected, 'hex'),
          Buffer.from(provided, 'hex')
        )) {
          return true;
        }
      }
    }

    throw new Error('Invalid signature');
  }

  // Example usage:
  // const secrets = [process.env.DITTO_WEBHOOK_SECRET];
  // verifyWebhookSignature(request.headers['ditto-signature'], rawBody, secrets);
  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  import time
  import base64
  from typing import List

  def verify_webhook_signature(
      signature_header: str,
      raw_body: bytes,
      secrets: List[str]
  ) -> bool:
      """
      Verifies webhook signature from Ditto

      Args:
          signature_header: The "ditto-signature" header value
          raw_body: Raw request body as bytes (NOT parsed JSON)
          secrets: List of base64-encoded webhook secrets

      Returns:
          True if signature is valid

      Raises:
          ValueError: If signature verification fails
      """
      if not signature_header:
          raise ValueError("Missing ditto-signature header")

      # 1. Parse header: "t=1234567890,v1=abc123,v1=def456"
      parts = signature_header.split(",")
      timestamp = parts[0].split("=")[1]
      provided_signatures = [p.split("=")[1] for p in parts[1:]]

      # 2. Validate timestamp (5-minute window)
      request_time = int(timestamp)
      current_time = int(time.time())
      age = abs(current_time - request_time)

      if age > 300:
          raise ValueError("Request too old")

      # 3. Reconstruct payload: "<timestamp>.<raw_body>"
      payload = f"{timestamp}.{raw_body.decode('utf-8')}"

      # 4. Compute expected signatures for all active secrets
      expected_signatures = []
      for secret in secrets:
          secret_bytes = base64.b64decode(secret)
          signature = hmac.new(
              secret_bytes,
              payload.encode('utf-8'),
              hashlib.sha256
          ).hexdigest()
          expected_signatures.append(signature)

      # 5. Verify at least one signature matches (constant-time comparison)
      for expected in expected_signatures:
          for provided in provided_signatures:
              if hmac.compare_digest(expected, provided):
                  return True

      raise ValueError("Invalid signature")

  # Example usage:
  # secrets = [os.getenv("DITTO_WEBHOOK_SECRET")]
  # verify_webhook_signature(signature_header, raw_body, secrets)
  ```

  ```rust Rust theme={null}
  // Cargo.toml dependencies: base64, hmac, sha2, chrono

  use base64::Engine;
  use chrono::{DateTime, TimeDelta, Utc};
  use hmac::{Hmac, Mac};
  use sha2::Sha256;
  use std::collections::HashSet;

  fn verify_signature(
      secrets: &[Vec<u8>],
      header: &str,
      body: &[u8],
      threshold: TimeDelta,
  ) -> Result<(), String> {
      // 1. Parse header: "t=1234567890,v1=abc123,v1=def456"
      let (timestamp_part, remaining) = header
          .split_once(',')
          .ok_or("Invalid header format")?;

      let (_key, secs_since_epoch) = timestamp_part
          .split_once('=')
          .ok_or("Invalid timestamp format")?;

      // 2. Validate timestamp freshness (replay protection)
      let timestamp = DateTime::from_timestamp(
          secs_since_epoch.parse().map_err(|_| "Invalid timestamp")?,
          0,
      )
      .ok_or("Invalid timestamp value")?;

      let age = Utc::now() - timestamp;
      if age > threshold {
          return Err("Request too old".to_string());
      }

      // 3. Compute expected signatures for all active secrets
      let mut expected_signatures = HashSet::new();
      for secret_bytes in secrets {
          let mut mac = Hmac::<Sha256>::new_from_slice(secret_bytes)
              .expect("HMAC can take key of any size");
          mac.update(secs_since_epoch.as_bytes());
          mac.update(b".");
          mac.update(body);
          expected_signatures.insert(hex::encode(mac.finalize().into_bytes()));
      }

      // 4. Verify at least one provided signature matches
      for signature_part in remaining.split(',') {
          let (version, provided_sig) = signature_part
              .split_once('=')
              .ok_or("Invalid signature format")?;

          if version != "v1" {
              continue;
          }

          if expected_signatures.contains(provided_sig) {
              return Ok(()); // Signature valid!
          }
      }

      Err("Invalid signature".to_string())
  }
  ```

  ```go Go theme={null}
  package main

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"crypto/subtle"
  	"encoding/base64"
  	"encoding/hex"
  	"fmt"
  	"strconv"
  	"strings"
  	"time"
  )

  // VerifyWebhookSignature verifies webhook signature from Ditto
  // signatureHeader: The "ditto-signature" header value
  // rawBody: Raw request body as bytes (NOT parsed JSON)
  // secrets: Slice of base64-encoded webhook secrets
  // Returns error if signature verification fails
  func VerifyWebhookSignature(signatureHeader string, rawBody []byte, secrets []string) error {
  	if signatureHeader == "" {
  		return fmt.Errorf("missing ditto-signature header")
  	}

  	// 1. Parse header: "t=1234567890,v1=abc123,v1=def456"
  	parts := strings.Split(signatureHeader, ",")
  	timestampPart := strings.Split(parts[0], "=")
  	timestamp := timestampPart[1]

  	var providedSignatures []string
  	for _, part := range parts[1:] {
  		sigPart := strings.Split(part, "=")
  		providedSignatures = append(providedSignatures, sigPart[1])
  	}

  	// 2. Validate timestamp (5-minute window)
  	requestTime, err := strconv.ParseInt(timestamp, 10, 64)
  	if err != nil {
  		return fmt.Errorf("invalid timestamp")
  	}

  	currentTime := time.Now().Unix()
  	age := currentTime - requestTime
  	if age < 0 {
  		age = -age
  	}

  	if age > 300 {
  		return fmt.Errorf("request too old")
  	}

  	// 3. Reconstruct payload: "<timestamp>.<raw_body>"
  	payload := fmt.Sprintf("%s.%s", timestamp, string(rawBody))

  	// 4. Compute expected signatures for all active secrets
  	var expectedSignatures []string
  	for _, secret := range secrets {
  		secretBytes, err := base64.StdEncoding.DecodeString(secret)
  		if err != nil {
  			continue
  		}

  		mac := hmac.New(sha256.New, secretBytes)
  		mac.Write([]byte(payload))
  		signature := hex.EncodeToString(mac.Sum(nil))
  		expectedSignatures = append(expectedSignatures, signature)
  	}

  	// 5. Verify at least one signature matches (constant-time comparison)
  	for _, expected := range expectedSignatures {
  		for _, provided := range providedSignatures {
  			if subtle.ConstantTimeCompare([]byte(expected), []byte(provided)) == 1 {
  				return nil
  			}
  		}
  	}

  	return fmt.Errorf("invalid signature")
  }

  // Example usage:
  // secrets := []string{os.Getenv("DITTO_WEBHOOK_SECRET")}
  // err := VerifyWebhookSignature(signatureHeader, rawBody, secrets)
  ```
</CodeGroup>

<Warning>
  **Use Raw Request Body**: Signature verification MUST use the raw request body bytes before any parsing or transformation.
  Most web frameworks automatically parse JSON, so you need to capture the raw buffer before parsing.

  **Common mistake**: Using `JSON.stringify(req.body)` instead of the original raw bytes.
  This will fail because JSON stringification may change whitespace or key ordering.
</Warning>

## Manage Secrets

This section covers the complete lifecycle of webhook secrets: creating new secrets, listing active secrets, and deleting old or compromised secrets.
You can manage secrets either through the Ditto Portal's web interface or programmatically via the HTTP API.

Webhook secrets have three timestamp fields that control their lifecycle:

| Field       | Type                      | Description                                                    |
| ----------- | ------------------------- | -------------------------------------------------------------- |
| `notBefore` | RFC 3339 DateTime         | Secret becomes valid at this time                              |
| `notAfter`  | RFC 3339 DateTime         | Secret expires at this time                                    |
| `rotated`   | RFC 3339 DateTime or null | Timestamp when secret was marked for rotation (null if active) |

### Add a New Secret

Webhook signatures are *disabled by default*.
To enable them, simply generate a webhook secret via the Ditto Portal or HTTP API.
Once you create a secret, Ditto automatically starts including the `ditto-signature` header in all webhook requests, allowing your server to validate request authenticity.

**Using Ditto Portal:**

<Steps>
  <Step title="Navigate to Your Database">
    In the Ditto Portal, select your database from the dashboard.
  </Step>

  <Step title="Access Webhook Configuration">
    Go to **Settings** → **Authentication** → **Webhook Providers**.

    Click `⋮` next to your webhook provider to open the configuration dialog.
  </Step>

  <Step title="Generate Secret">
    Click **Generate Secret**. The default validity period is *1 year*.

    <Frame>
      <img src="https://mintcdn.com/ditto-248bc0d1/z2K3wIYNo5RfWgvq/images/v5/webhook-generate-secret.png?fit=max&auto=format&n=z2K3wIYNo5RfWgvq&q=85&s=df5c658804fe52be89763158f3bd5f53" alt="Generate webhook secret button" width="1280" height="894" data-path="images/v5/webhook-generate-secret.png" />
    </Frame>
  </Step>

  <Step title="Display and Save Secret Securely">
    Click on the *eye* icon to display and copy the secret.

    <Frame>
      <img src="https://mintcdn.com/ditto-248bc0d1/z2K3wIYNo5RfWgvq/images/v5/webhook-secret-revealed.png?fit=max&auto=format&n=z2K3wIYNo5RfWgvq&q=85&s=32dae98bd265302ef3e9490ae759d8f1" alt="Revealed webhook secret with copy button" width="1280" height="894" data-path="images/v5/webhook-secret-revealed.png" />
    </Frame>
  </Step>
</Steps>

**Using the HTTP API:**

<Info>
  To use the HTTP API, you'll need an API key with appropriate permissions. See [HTTP API Authentication](/cloud/http-api/auth-and-params) for details on creating and managing API keys.
</Info>

```bash theme={null}
curl -X POST "https://{ditto-instance}/{database_id}/api/v4/auth/webhook/secret" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer {your-api-key}" \
  -d '{
    "provider": "myProvider",
    "notBefore": "2025-01-21T10:00:00Z",
    "notAfter": "2025-04-21T10:00:00Z"
  }'
```

**Response:**

```json theme={null}
{
  "secret": "9Ia9MRGrSVVPGVS64LqaUNUjAwJWs4sMMPK9TdVqHWM...",
  "notBefore": "2025-01-21T10:00:00Z",
  "notAfter": "2025-04-21T10:00:00Z",
  "rotated": null
}
```

### List Active Secrets

**Using Ditto Portal:**

In the webhook configuration dialog, active secrets are displayed in the **Signature Verification** section with their validity period and status.

<Frame>
  <img src="https://mintcdn.com/ditto-248bc0d1/z2K3wIYNo5RfWgvq/images/v5/webhook-secret-active.png?fit=max&auto=format&n=z2K3wIYNo5RfWgvq&q=85&s=5fe40a24f5ced96132755ab0d68a3a1e" alt="Active webhook secret in portal" width="1280" height="894" data-path="images/v5/webhook-secret-active.png" />
</Frame>

**Using the HTTP API:**

```bash theme={null}
curl -X GET "https://{ditto-instance}/{database_id}/api/v4/auth/webhook/secret?provider=myProvider" \
  -H "Authorization: Bearer {your-api-key}"
```

**Response:**

```json theme={null}
[
  {
    "secret": "9Ia9MRGrSVVPGVS64LqaUNUjAwJWs4sMMPK9TdVqHWM...",
    "notBefore": "2024-04-21T10:00:00Z",
    "notAfter": "2025-04-22T10:00:00Z",
    "rotated": "2025-04-21T10:00:00Z",
  },
  {
    "secret": "kL3pQwXtYzH8mN2vB7cR5dF9gJ4hK6lM1nP0qS8uV...",
    "notBefore": "2025-04-21T10:00:00Z",
    "notAfter": "2026-04-22T10:00:00Z",
    "rotated": null
  }
]
```

### Delete Active Secrets

You can delete old secrets after completing rotation or if you need to revoke a compromised secret.

**Using Ditto Portal:**

Click the **Delete** button next to the secret you want to remove. A confirmation dialog will appear to prevent accidental deletion.

<Frame>
  <img src="https://mintcdn.com/ditto-248bc0d1/z2K3wIYNo5RfWgvq/images/v5/webhook-delete-last.png?fit=max&auto=format&n=z2K3wIYNo5RfWgvq&q=85&s=89e2b74d81006d1b62004fc742ac05d2" alt="Delete webhook secret confirmation" width="1280" height="894" data-path="images/v5/webhook-delete-last.png" />
</Frame>

<Warning>
  If you delete your only active secret, webhook signature verification will be disabled until you generate a new secret.
</Warning>

**Using the HTTP API:**

```bash theme={null}
curl -X DELETE "https://{ditto-instance}/{database_id}/api/v4/auth/webhook/secret" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer {your-api-key}" \
  -d '{
    "provider": "myProvider",
    "secret": {
      "secret": "{secret-value-to-delete}",
      "notBefore": "2025-01-21T10:00:00Z",
      "notAfter": "2025-04-21T10:00:00Z"
    }
  }'
```

### Rotate Active Secrets

<Warning>
  **User-Initiated Process**: Secret rotation is your responsibility and requires coordinated action on both Ditto and your infrastructure. You must generate a new secret via the Ditto Portal or API, then update your webhook servers to validate with both the old and new secrets before removing the old one. Ditto does not automatically rotate secrets.
</Warning>

Ditto supports zero-downtime secret rotation by allowing multiple secrets to be active simultaneously.
When rotating webhook secrets, the old and new secrets have an **overlap period** where both are simultaneously valid and active.
During this overlap, Ditto signs all webhook requests with both secrets (including multiple `v1=` signature entries in the header), and your endpoint only needs to successfully validate against one of the provided signatures.
This dual-signature approach allows you to incrementally update your webhook servers without any authentication failures.
You can deploy the new secret to your infrastructure gradually, verify it works, and only then remove the old secret, eliminating the risk of downtime during the transition.

```mermaid theme={null}
gantt
    title Zero-Downtime Secret Rotation Timeline
    dateFormat YYYY-MM-DD HH:mm
    axisFormat %H:%M

    section Old Secret
    Valid Period           :done, old, 2025-01-21 12:00, 2025-01-23 12:00

    section New Secret
    Valid Period           :crit, new, 2025-01-22 12:00, 2025-01-24 12:00
```

**Timeline explanation:**

* **Old Secret Valid Period**: Secret is active from `notBefore` until the overlap begins
* **Overlap Period**: Both secrets are active and accepted - no downtime
* **New Secret Valid Period**: New secret continues after old secret's `notAfter`

#### Step-by-Step Rotation Guide

<Steps>
  <Step title="Generate New Secret">
    Mark the old secret for rotation and generate a new one:

    **Using Ditto Portal:**

    1. Select the active secret and click **Rotate Secret**.
    2. Confirm the rotation. The current secret will remain valid until its expiration date.

    <Frame>
      <img src="https://mintcdn.com/ditto-248bc0d1/z2K3wIYNo5RfWgvq/images/v5/webhook-rotate-dialog.png?fit=max&auto=format&n=z2K3wIYNo5RfWgvq&q=85&s=6e05f3ea116c0833a24b8c9e76f9dffa" alt="Rotate webhook secret dialog" width="1280" height="894" data-path="images/v5/webhook-rotate-dialog.png" />
    </Frame>

    3. After rotation, both secrets are visible. The old secret is marked as *Rotated* and the new one as *Active*.

    <Frame>
      <img src="https://mintcdn.com/ditto-248bc0d1/z2K3wIYNo5RfWgvq/images/v5/webhook-rotated-secrets.png?fit=max&auto=format&n=z2K3wIYNo5RfWgvq&q=85&s=a05c1ecef8c2b895be7bcd3c4f610c3f" alt="Both old and new secrets shown after rotation" width="1280" height="894" data-path="images/v5/webhook-rotated-secrets.png" />
    </Frame>

    4. Click the *eye* icon to reveal and copy the new secret immediately and store it securely.

    <Frame>
      <img src="https://mintcdn.com/ditto-248bc0d1/z2K3wIYNo5RfWgvq/images/v5/webhook-both-secrets-visible.png?fit=max&auto=format&n=z2K3wIYNo5RfWgvq&q=85&s=b9ecd9237b14fa5bbeec88059a8e0ded" alt="Both secrets revealed" width="1280" height="894" data-path="images/v5/webhook-both-secrets-visible.png" />
    </Frame>

    **Or via the HTTP API:**

    ```bash theme={null}
    curl -X POST "https://{ditto-instance}/{database_id}/api/v4/auth/webhook/secret/rotate" \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer {your-api-key}" \
      -d '{
        "provider": "myProvider",
        "rotate": {
          "secret": "{current-secret-value}",
          "notBefore": "2025-01-21T10:00:00Z",
          "notAfter": "2025-04-21T10:00:00Z"
        },
        "new": {
          "notBefore": "2025-01-21T15:00:00Z",
          "notAfter": "2025-07-21T15:00:00Z"
        }
      }'
    ```

    **Response:**

    ```json theme={null}
    {
      "secret": "aB2cD3eF4gH5iJ6kL7mN8oP9qR0sT1uV...",
      "notBefore": "2025-01-21T15:00:00Z",
      "notAfter": "2025-07-21T15:00:00Z",
      "rotated": null
    }
    ```
  </Step>

  <Step title="Update Your Webhook Endpoint">
    Add the new secret to your webhook endpoint, keeping the old secret active (see [implementation examples](#signature-verification)).
  </Step>

  <Step title="Deploy Your Database">
    Deploy your updated webhook endpoint with both secrets configured.
  </Step>

  <Step title="Verify Webhooks Work">
    Test that your webhook endpoint successfully verifies signatures.
  </Step>

  <Step title="Wait for Transition Period">
    Keep both secrets active for at least **24-48 hours** to ensure all in-flight requests complete and no cached configurations remain.
  </Step>

  <Step title="Remove Old Secret from Database">
    After the transition period, remove the old secret from your webhook endpoint configuration and redeploy.
  </Step>

  <Step title="Delete Old Secret from Ditto">
    Clean up by deleting the old secret.

    <Info>
      **Expired secrets are not automatically deleted:** Ditto does not garbage collect expired secrets. While expired secrets are no longer used in the `ditto-signature` header, they remain in internal storage until explicitly deleted. This design ensures that all secret management operations are user-initiated, giving you full control over your security configuration. It's your responsibility to delete old or expired secrets when they're no longer needed.
    </Info>

    **Using Ditto Portal:**

    1. Select the old rotated secret and click **Delete**.

    2. Confirm the deletion.

    <Frame>
      <img src="https://mintcdn.com/ditto-248bc0d1/z2K3wIYNo5RfWgvq/images/v5/webhook-delete-rotated.png?fit=max&auto=format&n=z2K3wIYNo5RfWgvq&q=85&s=fb4d78e676956e75c3ca23e618921419" alt="Delete old rotated webhook secret" width="1280" height="894" data-path="images/v5/webhook-delete-rotated.png" />
    </Frame>

    **Using the HTTP API:**

    ```bash theme={null}
    curl -X DELETE "https://{ditto-instance}/{database_id}/api/v4/auth/webhook/secret" \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer {your-api-key}" \
      -d '{
        "provider": "myProvider",
        "secret": {
          "secret": "{old-secret-value}",
          "notBefore": "2025-01-21T10:00:00Z",
          "notAfter": "2025-04-21T10:00:00Z"
        }
      }'
    ```
  </Step>
</Steps>

## Migration Path

In case you already have webhooks without signature verification, you can add security with zero downtime as follows:

<Steps>
  <Step title="Generate Secret Without Enforcing Verification">
    [Create a webhook secret](#add-a-new-secret) via the Ditto Portal or HTTP API, but don't start verifying signatures in your database yet.
  </Step>

  <Step title="Deploy Verification Code (Disabled)">
    Add the [signature verification code](#signature-verification) to your database with a configuration setting that keeps verification disabled. Deploy this version.
  </Step>

  <Step title="Enable Verification">
    Update your webhook endpoint configuration to enable signature verification.
  </Step>

  <Step title="Redeploy and Verify">
    Monitor logs to ensure all webhook requests are successfully verified and your clients can successfully connect.
    Your webhooks are now secured!
  </Step>
</Steps>

## Common Pitfalls

Here's a list of common pitfalls you may encounter when implementing your own webhook signature validation.

| Problem                                     | Possible Cause                              | Solution                                                                     |
| ------------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- |
| Signature never matches                     | Using parsed JSON body instead of raw bytes | Access raw request buffer before JSON parsing (see code examples)            |
| Intermittent failures                       | Clock skew > 5 minutes between servers      | Sync server clocks with NTP, increase tolerance if needed                    |
| Old signatures fail after rotation          | Not handling multiple signatures in header  | Parse ALL `v1=` entries from header, validate against all configured secrets |
| Base64 decoding errors                      | Using URL-safe base64                       | Use standard RFC 4648 base64                                                 |
| Constant validation errors after deployment | Forgot to update secret in environment      | Double-check environment variables are updated and deployment picked them up |
