Overview
Webhook signatures prove that a delivery came from Rundun and that the payload wasn't tampered with in transit. Without signature verification, any party who discovers your webhook URL could send fraudulent payloads that trigger actions in your system.
Rundun uses RFC 9421 HTTP Message Signatures (IETF standard, February 2024), with body integrity via companion RFC 9530 Content-Digest. This is a formal standard — not a custom HMAC implementation — which means there are libraries available in every language.
Headers sent on every delivery
Content-Digest: sha-256=:abc123base64hash==:
Signature-Input: sig1=("content-digest" "@method" "@target-uri");created=1716370320;keyid="rundun-key"
Signature: sig1=:base64sighere==:| Header | Purpose |
|---|---|
Content-Digest | SHA-256 hash of the raw request body — proves payload integrity |
Signature-Input | Describes which components were signed and when (created timestamp) |
Signature | HMAC-SHA256 over the listed components using your webhook secret |
Verification (Node.js)
import { createHmac, timingSafeEqual } from 'crypto'
function verifyRundunWebhook(req) {
const secret = process.env.WEBHOOK_SECRET
const maxAge = 5 * 60 * 1000 // reject if older than 5 minutes
// 1. Parse Signature-Input to get the created timestamp
const sigInput = req.headers['signature-input'] ?? ''
const createdMatch = sigInput.match(/created=(\d+)/)
if (!createdMatch) throw new Error('Missing created timestamp')
const created = parseInt(createdMatch[1], 10) * 1000
if (Date.now() - created > maxAge) throw new Error('Signature expired (replay protection)')
// 2. Verify Content-Digest matches the body
const body = req.body // raw Buffer
const expectedDigest = createHmac('sha256', secret).update(body).digest('base64')
const digestHeader = (req.headers['content-digest'] ?? '').replace('sha-256=:', '').replace(':', '')
if (!timingSafeEqual(Buffer.from(expectedDigest), Buffer.from(digestHeader))) {
throw new Error('Content-Digest mismatch — body tampered')
}
// 3. Verify the Signature over the signed components
const method = req.method.toUpperCase()
const targetUri = `https://${req.headers.host}${req.url}`
const sigBase = `"content-digest": ${req.headers['content-digest']}\n` +
`"@method": ${method}\n` +
`"@target-uri": ${targetUri}\n` +
`"@signature-params": ${sigInput}`
const expected = createHmac('sha256', secret).update(sigBase).digest('base64')
const actual = (req.headers['signature'] ?? '').replace('sig1=:', '').replace(':', '')
if (!timingSafeEqual(Buffer.from(expected), Buffer.from(actual))) {
throw new Error('Signature invalid')
}
}Verification (Python)
import hmac, hashlib, base64, time
from flask import request, abort
def verify_rundun_webhook():
secret = os.environ['WEBHOOK_SECRET'].encode()
max_age = 5 * 60 # 5 minutes
sig_input = request.headers.get('Signature-Input', '')
created_match = re.search(r'created=(\d+)', sig_input)
if not created_match:
abort(400, 'Missing created timestamp')
created = int(created_match.group(1))
if time.time() - created > max_age:
abort(400, 'Signature expired')
body = request.get_data()
expected_digest = base64.b64encode(
hmac.new(secret, body, hashlib.sha256).digest()
).decode()
digest_header = request.headers.get('Content-Digest', '').strip('sha-256=:').strip(':')
if not hmac.compare_digest(expected_digest, digest_header):
abort(400, 'Content-Digest mismatch')
# Full signature verification follows the same pattern as Node.js aboveReplay protection
The created timestamp in Signature-Input is the Unix timestamp when the signature was generated. Reject deliveries where created is more than 5 minutes in the past. This prevents an attacker who captured a valid delivery from replaying it later.
Setting your webhook secret
Set a strong random secret when dispatching a run:
{
"webhook": {
"url": "https://your-app.com/hooks/rundun",
"secret": "whsec_your-random-32-char-secret",
"events": ["run.completed"]
}
}Generate a secret with: node -e "console.log('whsec_' + require('crypto').randomBytes(20).toString('hex'))"