Every outbound webhook delivery from GuardHound is signed with HMAC-SHA256 so your handler can prove the request actually came from us and was not tampered with in transit.
| Header | Value |
|---|---|
X-GuardHound-Signature | sha256=<hex> — HMAC-SHA256 of the raw request body bytes, using your channel's signing secret as the key. |
X-GuardHound-Timestamp | Unix epoch in milliseconds when we generated the signature. Use this to reject replays older than your tolerance window. |
Content-Type | application/json |
The secret is shown once when you create a webhook channel in the portal (Account → Notifications). Save it somewhere safe — we cannot show it to you again. If you lose it, click Rotate signing secret on the channel to generate a new one.
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.GUARDHOUND_WEBHOOK_SECRET; // from your channel
const MAX_AGE_MS = 5 * 60 * 1000; // reject replays older than 5 min
// Capture the raw body bytes — re-serializing would break verification.
app.use('/guardhound', express.raw({ type: 'application/json' }));
app.post('/guardhound', (req, res) => {
const sigHeader = req.header('X-GuardHound-Signature') || '';
const ts = Number(req.header('X-GuardHound-Timestamp') || 0);
if (!ts || Math.abs(Date.now() - ts) > MAX_AGE_MS) {
return res.status(401).send('stale or missing timestamp');
}
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET)
.update(req.body) // raw Buffer
.digest('hex');
const a = Buffer.from(sigHeader);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('bad signature');
}
const payload = JSON.parse(req.body.toString('utf8'));
// ... handle payload.subject, payload.body, payload.metadata
res.status(200).send('ok');
});
import hashlib
import hmac
import os
import time
from flask import Flask, request, abort
SECRET = os.environ['GUARDHOUND_WEBHOOK_SECRET'].encode('utf-8')
MAX_AGE_MS = 5 * 60 * 1000
app = Flask(__name__)
@app.route('/guardhound', methods=['POST'])
def guardhound():
sig_header = request.headers.get('X-GuardHound-Signature', '')
try:
ts = int(request.headers.get('X-GuardHound-Timestamp', '0'))
except ValueError:
abort(401)
if not ts or abs(int(time.time() * 1000) - ts) > MAX_AGE_MS:
abort(401, 'stale or missing timestamp')
body = request.get_data() # raw bytes
expected = 'sha256=' + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig_header, expected):
abort(401, 'bad signature')
# ... handle request.get_json()
return ('ok', 200)
Both crypto.timingSafeEqual (Node) and hmac.compare_digest (Python) compare two byte strings in constant time, so an attacker can't infer the correct signature one byte at a time by measuring response latency. A naive == comparison short-circuits on the first mismatching byte and leaks that information. Always use the constant-time helper.
Channels created before signing was added continue to deliver without a signature header so your integration does not break in flight. To start signing, click Rotate signing secret on the channel, store the returned secret, and add the verification snippet to your handler. After the rotation, every subsequent delivery to that channel will carry the headers above.