Seal Docs

Signature Verification

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

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

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

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

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)
// ✅ 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()
// ✅ 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:

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

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

# 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

// ❌ 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

// ❌ 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:

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

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

Last updated on

On this page