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

How It Works

cStar uses a Stripe-style signature format. Every delivery includes:

  • An X-Signature header in the form t=<unix_timestamp>,v1=<hex_hmac>
  • An X-Timestamp header (ISO 8601) for convenience

The HMAC-SHA256 is computed over ${timestamp}.${rawBody}, where timestamp is the value from the t= field and rawBody is the bytes of the request body exactly as received.

To verify a delivery:

  1. Parse X-Signature to extract t and v1.
  2. Compute the expected HMAC over ${t}.${rawBody} using your webhook secret.
  3. Compare v1 to the expected HMAC in constant time.
  4. Reject if Math.abs(now - t) > 300 (5-minute replay window).

The key detail: verify against the raw body string, not the parsed-then-re-serialized JSON. Parsing and re-serializing can change key order or whitespace, which breaks the signature.

Legacy format note: earlier deliveries used X-Signature: sha256=<hex> over the raw body alone. cStar still accepts that format on inbound verification, so existing verifier code keeps working. New deliveries always emit the Stripe-style format with replay protection. Migrate to the snippets below when you can.

Verification: Node.js

const crypto = require('crypto');

function parseStripeHeader(header) {
  const parts = {};
  for (const piece of header.split(',')) {
    const eq = piece.indexOf('=');
    if (eq === -1) continue;
    const key = piece.slice(0, eq).trim();
    const value = piece.slice(eq + 1).trim();
    if (key === 't') parts.t = parseInt(value, 10);
    else if (key === 'v1') parts.v1 = value;
  }
  return parts;
}

function verifyWebhook(rawBody, signatureHeader, secret, tolerance = 300) {
  const { t, v1 } = parseStripeHeader(signatureHeader);
  if (!t || !v1) return false;

  // Reject replays outside the tolerance window
  if (Math.abs(Date.now() / 1000 - t) > tolerance) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex');

  return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}

// Express example. Use express.raw() so req.body is a Buffer.
app.post('/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-signature'];
    const rawBody = req.body.toString();

    if (!verifyWebhook(rawBody, signature, process.env.WEBHOOK_SECRET)) {
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(rawBody);
    console.log('Verified event:', event.type);
    res.sendStatus(200);
  }
);

Verification: Python

import hmac
import hashlib
import time

def parse_stripe_header(header: str):
    parts = {}
    for piece in header.split(','):
        if '=' not in piece:
            continue
        key, _, value = piece.partition('=')
        key = key.strip()
        value = value.strip()
        if key == 't':
            parts['t'] = int(value)
        elif key == 'v1':
            parts['v1'] = value
    return parts

def verify_webhook(raw_body: bytes, signature: str, secret: str, tolerance: int = 300) -> bool:
    parts = parse_stripe_header(signature)
    t, v1 = parts.get('t'), parts.get('v1')
    if t is None or v1 is None:
        return False

    if abs(time.time() - t) > tolerance:
        return False

    expected = hmac.new(
        secret.encode(),
        f'{t}.'.encode() + raw_body,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(v1, expected)

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

    if not verify_webhook(raw_body, signature, WEBHOOK_SECRET):
        return 'Invalid signature', 401

    payload = request.get_json()
    print(f"Verified event: {payload['type']}")
    return 'OK', 200

Verification: PHP

function parseStripeHeader(string $header): array {
    $parts = [];
    foreach (explode(',', $header) as $piece) {
        if (!str_contains($piece, '=')) continue;
        [$key, $value] = explode('=', $piece, 2);
        $parts[trim($key)] = trim($value);
    }
    return $parts;
}

function verifyWebhook(string $rawBody, string $signature, string $secret, int $tolerance = 300): bool {
    $parts = parseStripeHeader($signature);
    $t = $parts['t'] ?? null;
    $v1 = $parts['v1'] ?? null;
    if (!$t || !$v1) return false;

    if (abs(time() - (int) $t) > $tolerance) return false;

    $expected = hash_hmac('sha256', "{$t}.{$rawBody}", $secret);
    return hash_equals($expected, $v1);
}

$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SIGNATURE'];

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

$payload = json_decode($rawBody, true);

Timing-Safe Comparison

Always use timing-safe comparison: crypto.timingSafeEqual in Node, hmac.compare_digest in Python, hash_equals in PHP. Plain === comparison leaks information about which characters matched, making it possible to forge signatures byte by byte. This isn't theoretical, it's a textbook attack.

Replay Protection

The Stripe-style signature already bakes timestamp protection into verification: the t= field is part of the HMAC input, and the verifier code above rejects timestamps outside a 5-minute window. That's the primary defense.

Two layers in total:

1. Timestamp window. Built into verifyWebhook. Tunable via the tolerance parameter, default 300 seconds. Tighten it if you want a smaller window. The verifier returns false when the timestamp is outside tolerance.

2. Event ID dedup. Track which ids you've processed. Avoids handling the same event twice when retries happen.

const processed = new Set();

function handleWebhook(eventId, payload) {
  if (processed.has(eventId)) return; // already handled
  processed.add(eventId);
  // Process...
}

In production, persist event IDs in a database or Redis with a TTL of at least 24 hours (the longest retry window).

Rotating Secrets

If you suspect a secret is compromised:

  1. Go to Settings → Team → Webhooks.
  2. Click the webhook, then Regenerate Secret.
  3. Update your server with the new secret.
  4. Previous deliveries in the log still show their original signatures.

Best Practices

  • Always verify signatures before processing payloads
  • Use timing-safe comparison functions
  • Store secrets in environment variables, never in source
  • Log failed verification attempts (they may indicate probing)
  • Don't log full payloads (they may contain customer PII)
  • Rotate secrets periodically or after team membership changes

Related