Webhook Security

Verify webhook signatures to ensure payloads are authentic.

Why Verify Signatures?

Anyone who knows your webhook URL could send fake events. Signature verification proves the payload came from cStar.

How Signing Works

  1. cStar creates a signature using your webhook's secret
  2. The signature is sent in the X-Signature header
  3. You compute the expected signature and compare

The X-Signature Header

X-Signature: sha256=d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592

The format is sha256= followed by the hex-encoded HMAC-SHA256 signature.

Verification Code

Node.js

const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, secret) {
  const expectedSignature = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(payload))
    .digest('hex');

  // Use timing-safe comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

// Express.js example
app.post('/webhook', express.json(), (req, res) => {
  const signature = req.headers['x-signature'];
  const isValid = verifyWebhookSignature(
    req.body,
    signature,
    process.env.WEBHOOK_SECRET
  );

  if (!isValid) {
    return res.status(401).send('Invalid signature');
  }

  // Process the webhook
  console.log('Received event:', req.body.type);
  res.sendStatus(200);
});

Python

import hmac
import hashlib
import json

def verify_webhook_signature(payload, signature, secret):
    expected = 'sha256=' + hmac.new(
        secret.encode('utf-8'),
        json.dumps(payload, separators=(',', ':')).encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(signature, expected)

# Flask example
@app.route('/webhook', methods=['POST'])
def webhook():
    signature = request.headers.get('X-Signature')
    payload = request.get_json()

    if not verify_webhook_signature(payload, signature, WEBHOOK_SECRET):
        return 'Invalid signature', 401

    # Process the webhook
    print(f"Received event: {payload['type']}")
    return 'OK', 200

PHP

function verifyWebhookSignature($payload, $signature, $secret) {
    $expected = 'sha256=' . hash_hmac(
        'sha256',
        json_encode($payload, JSON_UNESCAPED_SLASHES),
        $secret
    );

    return hash_equals($expected, $signature);
}

// Usage
$payload = json_decode(file_get_contents('php://input'), true);
$signature = $_SERVER['HTTP_X_SIGNATURE'];

if (!verifyWebhookSignature($payload, $signature, $webhookSecret)) {
    http_response_code(401);
    exit('Invalid signature');
}

Security Best Practices

Do:

  • Always verify signatures before processing
  • Use timing-safe comparison functions
  • Store secrets in environment variables
  • Rotate secrets periodically
  • Log failed verification attempts

Don't:

  • Skip verification in production
  • Use simple string comparison (timing attack risk)
  • Hardcode secrets in code
  • Log the full payload (may contain PII)

Rotating Secrets

If you suspect a secret is compromised:

  1. Go to Settings → Integrations → Webhooks
  2. Click the webhook to edit
  3. Click Regenerate Secret
  4. Update your server with the new secret
  5. Old deliveries will still use the old secret

Replay Protection

Use the X-Event-ID header to prevent replay attacks:

const processedEvents = new Set();

function handleWebhook(req) {
  const eventId = req.headers['x-event-id'];

  if (processedEvents.has(eventId)) {
    return; // Already processed
  }

  processedEvents.add(eventId);
  // Process event...
}

Related