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
- Seal constructs a signed content string:
{eventId}.{timestamp}.{payload} - Seal generates an HMAC-SHA256 hash of that string using your webhook secret
- The signature is sent as
v1={hex}in theX-Seal-Signatureheader, along withX-Seal-Event-IdandX-Seal-Timestampheaders - Your server reconstructs the same signed content and generates the hash
- 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', 200Go
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 signatureCommon 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_xxx3. 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
- Always Verify: Never skip signature verification, even in development
- Use HTTPS: Webhook URLs must use HTTPS in production
- Rotate Secrets: Rotate webhook secrets every 90 days
- Store Securely: Keep secrets in environment variables or secrets manager
- Log Failures: Monitor and alert on signature verification failures
- Rate Limit: Implement rate limiting on webhook endpoints
- Validate Payload: Verify payload structure after signature check
Troubleshooting
Signature Verification Fails
- Check Secret: Ensure you're using the correct webhook secret (not API key)
- Check Encoding: Verify you're using UTF-8 encoding
- Check Body: Ensure you're using the raw request body
- Check Header: Verify you're reading
X-Seal-Signature,X-Seal-Event-Id, andX-Seal-Timestamp - 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
- Webhook Setup - Configure webhook endpoints
- Webhooks API - API reference
- Error Handling - Handle verification errors
Last updated on