Aller au contenu principal

Verifying signatures

Every webhook DZBuild sends carries an HMAC-SHA256 signature. Your handler MUST verify it before trusting the body.

The recipe

expected = hex( hmac_sha256( WEBHOOK_SECRET, X-DZ-Timestamp + "." + sha256_hex(raw_body) ) )

if (!constant_time_equal(expected, X-DZ-Signature)) reject 401
if (abs(now - X-DZ-Timestamp) > 300) reject 401 # ±5 min replay window

Three rules to be safe:

  1. Use raw body bytes for the inner SHA-256. Re-serializing the JSON gives a different hash.
  2. Constant-time compare the signature. A regular == leaks timing info that aids brute-force.
  3. Reject stale timestamps (>5 minutes off your server's clock). Run NTP.

Code

Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const WEBHOOK_SECRET = process.env.DZBUILD_WEBHOOK_SECRET;

// IMPORTANT: capture the raw body for HMAC, separately from the parsed JSON.
app.post('/webhooks/dzbuild',
express.raw({ type: 'application/json' }),
(req, res) => {
const ts = req.get('X-DZ-Timestamp');
const sig = req.get('X-DZ-Signature');
if (!ts || !sig) return res.status(401).end();

if (Math.abs(Math.floor(Date.now()/1000) - Number(ts)) > 300) {
return res.status(401).end(); // stale or future timestamp
}
const bodyHash = crypto.createHash('sha256').update(req.body).digest('hex');
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${ts}.${bodyHash}`)
.digest('hex');

if (expected.length !== sig.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return res.status(401).end();
}

// Verified — now you can parse and act.
const event = JSON.parse(req.body.toString('utf8'));
console.log('verified', event.event, event.data);
res.status(200).end(); // ack ASAP
});

app.listen(3000);
PHP (raw)
<?php
$secret = getenv('DZBUILD_WEBHOOK_SECRET');
$body = file_get_contents('php://input'); // raw body
$ts = $_SERVER['HTTP_X_DZ_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_DZ_SIGNATURE'] ?? '';

if ($ts === '' || $sig === '') { http_response_code(401); exit; }
if (abs(time() - (int)$ts) > 300) { http_response_code(401); exit; }

$bodyHash = hash('sha256', $body);
$expected = hash_hmac('sha256', $ts . '.' . $bodyHash, $secret);

if (!hash_equals($expected, strtolower($sig))) {
http_response_code(401);
exit;
}

$event = json_decode($body, true);
// Process $event['event'], $event['data'], $event['delivery_id']
http_response_code(200);

If you're in Laravel, use a route middleware or a controller that reads $request->getContent() for the raw body. Disable CSRF on the webhook route.

Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = os.environ['DZBUILD_WEBHOOK_SECRET'].encode()

@app.post('/webhooks/dzbuild')
def receive():
ts = request.headers.get('X-DZ-Timestamp', '')
sig = request.headers.get('X-DZ-Signature', '')
if not ts or not sig: abort(401)
if abs(int(time.time()) - int(ts)) > 300: abort(401)

body = request.get_data() # raw bytes — DO NOT use request.json
body_hash = hashlib.sha256(body).hexdigest()
expected = hmac.new(WEBHOOK_SECRET, f"{ts}.{body_hash}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig.lower()): abort(401)

event = request.get_json()
print('verified', event['event'], event['data'])
return '', 200
Go (net/http)
package main

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

var secret = []byte(os.Getenv("DZBUILD_WEBHOOK_SECRET"))

func receive(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil { http.Error(w, "read", 400); return }

ts := r.Header.Get("X-DZ-Timestamp")
sig := r.Header.Get("X-DZ-Signature")
if ts == "" || sig == "" { http.Error(w, "no sig", 401); return }

tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil { http.Error(w, "ts", 401); return }
if abs(time.Now().Unix() - tsInt) > 300 { http.Error(w, "stale", 401); return }

h := sha256.Sum256(body)
bodyHash := hex.EncodeToString(h[:])
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(ts + "." + bodyHash))
expected := hex.EncodeToString(mac.Sum(nil))

if !hmac.Equal([]byte(expected), []byte(sig)) {
http.Error(w, "bad sig", 401); return
}
w.WriteHeader(http.StatusOK)
}

func abs(x int64) int64 { if x < 0 { return -x }; return x }

Common mistakes

MistakeSymptomFix
Re-serializing JSON body before hashingSignature never matchesUse raw body bytes — see framework notes above
Server clock drift"Timestamp out of window" rejectionsRun NTP, check timedatectl status on Linux
Comparing with == instead of constant-timeSubtle timing-attack vulnerabilityUse crypto.timingSafeEqual / hmac.compare_digest / hash_equals
Logging the secret on diskSecret ends up in your log filesDon't log it; use a secrets store; rotate if it leaks
Returning 200 immediately and processing laterLost events when your worker crashesEither persist to your own queue first then ack, OR do the work synchronously and ack last

Idempotency on your side

The same delivery_id MAY arrive more than once if our delivery layer thinks the first attempt failed (network partition, your endpoint slow). Dedupe on the receiving side:

INSERT INTO webhook_log (delivery_id, event, body) VALUES (?, ?, ?);
-- Catch UNIQUE violation on delivery_id → already processed, return 200 anyway

This pattern means even if our retry pings you again, you do the work once and ack quickly.