Webhook signatures
Every Rundun webhook delivery is signed using RFC 9421 HTTP Message Signatures (IETF standard, February 2024). Body integrity is guaranteed by RFC 9530 Content-Digest.
Verifying signatures before processing a webhook is strongly recommended. It ensures the delivery came from Rundun and the payload was not tampered with in transit.
Headers sent on every delivery
Content-Digest: sha-256=:<base64-sha256-of-body>:
Signature-Input: sig1=("content-digest" "@method" "@target-uri");created=1748000000;keyid="rundun-key"
Signature: sig1=:<base64-hmac-sha256>:
| Header | Purpose |
|---|---|
Content-Digest |
SHA-256 hash of the raw request body (RFC 9530). Proves the payload was not tampered with. |
Signature-Input |
Describes which components were signed and when (created is a Unix timestamp). |
Signature |
HMAC-SHA256 over the listed components, computed using your webhook secret. |
Verification algorithm
- Parse
createdfromSignature-Input. Reject the request ifnow - created > 300(5 minutes). This prevents replay attacks. - Compute SHA-256 of the raw request body. Compare with the value in
Content-Digest. Reject if they differ. - Reconstruct the signature base string from the signed components in the order listed by
Signature-Input. - Compute HMAC-SHA256 over the base string using your webhook secret.
- Compare your computed signature with the value in the
Signatureheader using a timing-safe comparison.
Node.js example
This example performs full verification without external dependencies:
import { createHmac, createHash, timingSafeEqual } from 'node:crypto'
interface WebhookHeaders {
'content-digest': string
'signature-input': string
'signature': string
}
/**
* Verify a Rundun webhook delivery.
* @param rawBody - The raw request body string (do NOT parse to JSON first)
* @param headers - Lowercase header names from the incoming request
* @param secret - The webhook secret you set when dispatching the run
* @returns true if the signature is valid, false otherwise
*/
function verifyRundunWebhook(
rawBody: string,
headers: WebhookHeaders,
secret: string,
): boolean {
// Step 1: Replay protection — reject if created timestamp is >5 minutes ago
const sigInput = headers['signature-input']
const createdMatch = sigInput?.match(/created=(\d+)/)
if (!createdMatch) return false
const created = parseInt(createdMatch[1], 10)
if (Math.floor(Date.now() / 1000) - created > 300) return false
// Step 2: Verify Content-Digest (body integrity)
const bodyHash = createHash('sha256').update(rawBody, 'utf8').digest('base64')
const expectedDigest = `sha-256=:${bodyHash}:`
const actualDigest = headers['content-digest']
if (
!timingSafeEqual(
Buffer.from(expectedDigest, 'utf8'),
Buffer.from(actualDigest, 'utf8'),
)
) return false
// Step 3: Reconstruct the signature base string
// Components listed in Signature-Input: "content-digest" "@method" "@target-uri"
// Append "@signature-params" as the final component
const sigParamsValue = sigInput.replace(/^sig1=/, '')
const sigBase = [
`"content-digest": ${actualDigest}`,
`"@method": POST`,
// @target-uri is the full URL — extract from Signature-Input context or pass it in
// For simplicity, use the path. For full RFC 9421, pass the full URL.
`"@signature-params": ${sigParamsValue}`,
].join('\n')
// Step 4–5: Compute and compare HMAC-SHA256
const expected = createHmac('sha256', secret).update(sigBase, 'utf8').digest('base64')
const actualMatch = headers['signature']?.match(/sig1=:([^:]+):/)
if (!actualMatch) return false
const actual = actualMatch[1]
return timingSafeEqual(
Buffer.from(expected, 'base64'),
Buffer.from(actual, 'base64'),
)
}
Express.js integration
import express from 'express'
const app = express()
// IMPORTANT: Use express.raw() to get the raw body — JSON parsing changes the string
app.post('/hooks/rundun', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8')
const secret = process.env.RUNDUN_WEBHOOK_SECRET!
const isValid = verifyRundunWebhook(rawBody, {
'content-digest': req.headers['content-digest'] as string,
'signature-input': req.headers['signature-input'] as string,
'signature': req.headers['signature'] as string,
}, secret)
if (!isValid) {
return res.status(401).json({ error: 'invalid_signature' })
}
const payload = JSON.parse(rawBody)
// process payload...
res.status(200).json({ received: true })
})
Using the http-message-signatures package
For full RFC 9421 compliance including @target-uri and @authority components, use the http-message-signatures package:
npm install http-message-signatures
import { verifyMessage } from 'http-message-signatures'
const isValid = await verifyMessage(
{ headers: req.headers, method: req.method, url: req.url },
{
async getPublicKey(keyId) {
// Return the shared HMAC secret as a symmetric key
return { id: keyId, alg: 'hmac-sha256', key: Buffer.from(process.env.RUNDUN_WEBHOOK_SECRET!) }
},
},
)
Python example
import hashlib
import hmac
import base64
import time
import re
def verify_rundun_webhook(
raw_body: bytes,
content_digest: str,
signature_input: str,
signature: str,
secret: str,
) -> bool:
"""
Verify a Rundun webhook delivery.
raw_body must be the raw bytes before any JSON parsing.
"""
# Step 1: Replay protection
match = re.search(r'created=(\d+)', signature_input)
if not match:
return False
created = int(match.group(1))
if int(time.time()) - created > 300:
return False
# Step 2: Verify Content-Digest
body_hash = base64.b64encode(hashlib.sha256(raw_body).digest()).decode()
expected_digest = f'sha-256=:{body_hash}:'
if not hmac.compare_digest(expected_digest, content_digest):
return False
# Step 3: Reconstruct signature base string
sig_params = signature_input.replace('sig1=', '')
sig_base = '\n'.join([
f'"content-digest": {content_digest}',
'"@method": POST',
f'"@signature-params": {sig_params}',
])
# Step 4–5: Compute and compare HMAC-SHA256
expected_sig = base64.b64encode(
hmac.new(secret.encode(), sig_base.encode(), hashlib.sha256).digest()
).decode()
actual_match = re.search(r'sig1=:([^:]+):', signature)
if not actual_match:
return False
actual_sig = actual_match.group(1)
return hmac.compare_digest(expected_sig, actual_sig)
# Flask example
from flask import Flask, request, abort
import json
app = Flask(__name__)
@app.post('/hooks/rundun')
def webhook():
is_valid = verify_rundun_webhook(
raw_body=request.get_data(),
content_digest=request.headers.get('Content-Digest', ''),
signature_input=request.headers.get('Signature-Input', ''),
signature=request.headers.get('Signature', ''),
secret=os.environ['RUNDUN_WEBHOOK_SECRET'],
)
if not is_valid:
abort(401)
payload = request.get_json()
# process payload...
return {'received': True}
Important: use the raw body
The signature covers the raw body bytes. Any transformation — JSON parsing and re-serializing, whitespace normalization, encoding changes — will cause verification to fail. Always read the raw request body before parsing it as JSON.