Verify webhook signatures
Every webhook delivery carries a Cobuntu-Signature header. You
must verify it before trusting the payload — otherwise anyone
who knows your URL can fake events.
The signature format
Cobuntu-Signature: t=1716700000,v1=2b3c4d5e6f7g8h9i...t— Unix timestamp (seconds). The age of the signed body.v1— HMAC-SHA256 hex digest of{t}.{rawBody}, keyed by your webhook's signing secret.
Verify in Node / Express
import crypto from "crypto";
import express from "express";
const app = express();
// IMPORTANT: use raw body, NOT a parsed JSON body. The signature
// is over the exact bytes Cobuntu sent.
app.post(
"/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.get("Cobuntu-Signature") || "";
if (!verifySignature(sig, req.body, process.env.COBUNTU_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
// … handle the event
res.status(200).end();
},
);
function verifySignature(header, rawBody, secret) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=", 2)),
);
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) return false;
// Reject ancient signatures (replay protection — 5 min window).
const age = Math.floor(Date.now() / 1000) - Number(t);
if (Number.isNaN(age) || age > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody.toString("utf8")}`)
.digest("hex");
// Constant-time compare to avoid timing attacks.
const a = Buffer.from(v1, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}Verify in Python
import hashlib
import hmac
import time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["COBUNTU_WEBHOOK_SECRET"].encode("utf-8")
@app.route("/webhook", methods=["POST"])
def webhook():
sig = request.headers.get("Cobuntu-Signature", "")
if not verify_signature(sig, request.get_data()):
abort(401)
event = request.get_json()
# … handle
return "", 200
def verify_signature(header: str, raw_body: bytes) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
return False
if abs(int(time.time()) - int(t)) > 300:
return False
expected = hmac.new(SECRET, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)Don't forget
- Use the raw bytes of the body — not a re-stringified JSON. Parsing then re-serializing produces different bytes and signature verification fails.
- Constant-time compare — string
===leaks timing. - Reject old signatures — 5 minutes is a reasonable window.
- Rotate the secret if it leaks. cobuntu-admin → Integrations → Webhooks → Rotate.