---
title: Signature Verification
description: Verify webhook authenticity using HMAC-SHA256 signatures
---

# Signature Verification

All webhook requests from Seal include an HMAC-SHA256 signature in the `X-Seal-Signature` header. Always verify this signature to ensure the request is authentic and hasn't been tampered with.

## Why Verify Signatures?

Without signature verification, anyone who knows your webhook URL could send fake events to your endpoint. Signature verification ensures:

- **Authenticity**: The request came from Seal
- **Integrity**: The payload hasn't been modified
- **Security**: Protection against replay attacks

> **Warn**
>
> **Security Critical**: Never process webhook events without verifying the
> signature. Unverified webhooks are a security vulnerability.

## How Signatures Work

1. Seal constructs a signed content string: `{eventId}.{timestamp}.{payload}`
2. Seal generates an HMAC-SHA256 hash of that string using your webhook secret
3. The signature is sent as `v1={hex}` in the `X-Seal-Signature` header, along with `X-Seal-Event-Id` and `X-Seal-Timestamp` headers
4. Your server reconstructs the same signed content and generates the hash
5. If the signatures match, the request is authentic

## Verification Implementation

### Node.js / TypeScript

```typescript
import { createHmac, timingSafeEqual } from "crypto";

function verifyWebhookSignature(
  payload: string,
  signature: string,
  eventId: string,
  timestamp: string,
  secret: string
): boolean {
  const signedContent = `${eventId}.${timestamp}.${payload}`;
  const expectedSignature = `v1=${createHmac("sha256", secret)
    .update(signedContent)
    .digest("hex")}`;

  if (signature.length !== expectedSignature.length) {
    return false;
  }

  return timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

// Usage in Express
app.post(
  "/webhooks/seal",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.headers["x-seal-signature"] as string;
    const eventId = req.headers["x-seal-event-id"] as string;
    const timestamp = req.headers["x-seal-timestamp"] as string;
    const payload = req.body.toString("utf8");

    if (
      !verifyWebhookSignature(
        payload,
        signature,
        eventId,
        timestamp,
        process.env.WEBHOOK_SECRET!
      )
    ) {
      return res.status(401).send("Invalid signature");
    }

    // Process event
    const event = JSON.parse(payload);
    // ...

    res.status(200).send("OK");
  }
);
```

### Python

```python
import hmac
import hashlib

def verify_webhook_signature(
    payload: str, signature: str, event_id: str, timestamp: str, secret: str
) -> bool:
    """Verify webhook signature using HMAC-SHA256."""
    signed_content = f"{event_id}.{timestamp}.{payload}"
    expected_signature = "v1=" + hmac.new(
        secret.encode('utf-8'),
        signed_content.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(signature, expected_signature)

# Usage in Flask
from flask import Flask, request
import os

app = Flask(__name__)

@app.route('/webhooks/seal', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Seal-Signature')
    event_id = request.headers.get('X-Seal-Event-Id')
    timestamp = request.headers.get('X-Seal-Timestamp')
    payload = request.get_data(as_text=True)

    if not verify_webhook_signature(payload, signature, event_id, timestamp, os.environ['WEBHOOK_SECRET']):
        return 'Invalid signature', 401

    event = request.get_json()
    # ...

    return 'OK', 200
```

### Go

```go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "io"
    "net/http"
    "os"
)

func verifyWebhookSignature(payload, signature, eventID, timestamp, secret string) bool {
    signedContent := eventID + "." + timestamp + "." + payload
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(signedContent))
    expectedSignature := "v1=" + hex.EncodeToString(mac.Sum(nil))

    return subtle.ConstantTimeCompare(
        []byte(signature),
        []byte(expectedSignature),
    ) == 1
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    signature := r.Header.Get("X-Seal-Signature")
    eventID := r.Header.Get("X-Seal-Event-Id")
    timestamp := r.Header.Get("X-Seal-Timestamp")

    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Error reading body", http.StatusBadRequest)
        return
    }

    if !verifyWebhookSignature(string(body), signature, eventID, timestamp, os.Getenv("WEBHOOK_SECRET")) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    // Process event
    // ...

    w.WriteHeader(http.StatusOK)
}
```

## Important Implementation Details

### 1. Use Raw Request Body

The signature is computed on the **raw request body**, not the parsed JSON. Make sure to:

- Access the raw body before parsing
- Preserve exact whitespace and formatting
- Use the same encoding (UTF-8)

```typescript
// ✅ Correct: Use raw body
app.use("/webhooks/seal", express.raw({ type: "application/json" }));

// ❌ Wrong: Body is already parsed
app.use(express.json());
```

### 2. Use Timing-Safe Comparison

Always use timing-safe comparison functions to prevent timing attacks:

- **Node.js**: `crypto.timingSafeEqual()`
- **Python**: `hmac.compare_digest()`
- **Go**: `subtle.ConstantTimeCompare()`

```typescript
// ✅ Correct: Timing-safe
return timingSafeEqual(signatureBuffer, expectedBuffer);

// ❌ Wrong: Vulnerable to timing attacks
return signature === expectedSignature;
```

### 3. Handle Missing Signatures

Always check if the signature header exists:

```typescript
const signature = req.headers["x-seal-signature"];
const eventId = req.headers["x-seal-event-id"];
const timestamp = req.headers["x-seal-timestamp"];

if (!signature || !eventId || !timestamp) {
  return res.status(401).send("Missing required webhook headers");
}

if (!verifyWebhookSignature(payload, signature, eventId, timestamp, secret)) {
  return res.status(401).send("Invalid signature");
}
```

## Testing Signature Verification

### Generate Test Signature

```typescript
import { createHmac } from "crypto";

const payload = JSON.stringify({
  id: "evt_test123",
  type: "document.completed",
  created_at: "2024-01-27T12:00:00Z",
  data: { document_id: "doc_xyz789" },
});

const secret = "whsec_test_secret";

const eventId = "evt_test123";
const timestamp = Math.floor(Date.now() / 1000).toString();
const signedContent = `${eventId}.${timestamp}.${payload}`;
const signature = `v1=${createHmac("sha256", secret).update(signedContent).digest("hex")}`;

console.log("Signature:", signature);

fetch("http://localhost:3000/webhooks/seal", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-Seal-Signature": signature,
    "X-Seal-Event-Id": eventId,
    "X-Seal-Timestamp": timestamp,
  },
  body: payload,
});
```

### Verify Test Signature

```bash
# Expected signature for test payload
# Signature is computed on: {eventId}.{timestamp}.{payload}
echo -n 'evt_test123.1710288000.{"id":"evt_test123","type":"document.completed","api_version":"2025-01-01","created_at":"2026-03-12T12:00:00Z","organization_id":"org_abc123","data":{"document_id":"doc_xyz789"}}' | \
  openssl dgst -sha256 -hmac 'whsec_test_secret' -hex
# Prefix the result with "v1=" to get the full signature
```

## Common Pitfalls

### 1. Parsing Body Before Verification

```typescript
// ❌ Wrong: Body is parsed, signature won't match
app.use(express.json());
app.post("/webhooks/seal", (req, res) => {
  const payload = JSON.stringify(req.body); // Different formatting!
  // Signature verification will fail
});

// ✅ Correct: Use raw body
app.use("/webhooks/seal", express.raw({ type: "application/json" }));
app.post("/webhooks/seal", (req, res) => {
  const payload = req.body.toString("utf8");
  // Signature verification will succeed
});
```

### 2. Using Wrong Secret

```typescript
// ❌ Wrong: Using API key instead of webhook secret
const secret = process.env.SEAL_API_KEY;

// ✅ Correct: Use webhook secret from creation response
const secret = process.env.WEBHOOK_SECRET; // whsec_xxx
```

### 3. Not Handling Signature Rotation

When rotating secrets, support both old and new secrets temporarily:

```typescript
function verifyWithFallback(
  payload: string,
  signature: string,
  eventId: string,
  timestamp: string
): boolean {
  const currentSecret = process.env.WEBHOOK_SECRET!;
  const previousSecret = process.env.WEBHOOK_SECRET_OLD;

  if (
    verifyWebhookSignature(
      payload,
      signature,
      eventId,
      timestamp,
      currentSecret
    )
  ) {
    return true;
  }

  if (
    previousSecret &&
    verifyWebhookSignature(
      payload,
      signature,
      eventId,
      timestamp,
      previousSecret
    )
  ) {
    console.warn("Webhook verified with old secret - update sender");
    return true;
  }

  return false;
}
```

## Security Best Practices

1. **Always Verify**: Never skip signature verification, even in development
2. **Use HTTPS**: Webhook URLs must use HTTPS in production
3. **Rotate Secrets**: Rotate webhook secrets every 90 days
4. **Store Securely**: Keep secrets in environment variables or secrets manager
5. **Log Failures**: Monitor and alert on signature verification failures
6. **Rate Limit**: Implement rate limiting on webhook endpoints
7. **Validate Payload**: Verify payload structure after signature check

## Troubleshooting

### Signature Verification Fails

1. **Check Secret**: Ensure you're using the correct webhook secret (not API key)
2. **Check Encoding**: Verify you're using UTF-8 encoding
3. **Check Body**: Ensure you're using the raw request body
4. **Check Header**: Verify you're reading `X-Seal-Signature`, `X-Seal-Event-Id`, and `X-Seal-Timestamp`
5. **Test Locally**: Generate a test signature and verify it works

### Debug Signature Mismatch

```typescript
function debugSignature(
  payload: string,
  receivedSignature: string,
  eventId: string,
  timestamp: string,
  secret: string
) {
  const signedContent = `${eventId}.${timestamp}.${payload}`;
  const expectedSignature = `v1=${createHmac("sha256", secret)
    .update(signedContent)
    .digest("hex")}`;

  console.log("Received signature:", receivedSignature);
  console.log("Expected signature:", expectedSignature);
  console.log("Event ID:", eventId);
  console.log("Timestamp:", timestamp);
  console.log("Payload length:", payload.length);
  console.log("Secret prefix:", secret.substring(0, 10) + "...");
}
```

## Next Steps

- **[Webhook Setup](/docs/webhooks/setup)** - Configure webhook endpoints
- **[Webhooks API](/docs/api-reference/webhooks)** - API reference
- **[Error Handling](/docs/getting-started/error-handling)** - Handle verification errors
