Verifying deliveries
Check that a delivery came from Ghost, with examples in Node.js, Python and Go
Check X-Ghost-Signature-256:
- Read the raw request body as bytes. Do not parse the JSON and serialize it again: the signature covers the exact bytes Ghost sent, and a round trip through a JSON parser changes them.
- Read
X-Ghost-Timestampand reject the delivery if it is more than five minutes from your clock. The timestamp is inside the signed data, so a delivery captured by someone else cannot be replayed later with a fresh one. - Compute HMAC-SHA256 with the signing secret as the key over the timestamp, a
., and the raw body. Hex-encode it and putsha256=in front. - Compare the result with the header in constant time. An ordinary
==returns sooner the earlier the strings differ, which lets an attacker guess a signature one character at a time. - Deduplicate on
X-Ghost-Delivery. Delivery is at least once: a delivery your server handled but did not answer in time is sent again.
Reply with any 2xx status within 10 seconds, and do slow work after replying.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 5 * 60;
export function verifyGhostWebhook(
secret: string,
rawBody: Buffer,
timestamp: string | undefined,
signature: string | undefined,
): boolean {
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected = `sha256=${createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex')}`;
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}With Express, keep the raw body by parsing it with express.raw:
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyGhostWebhook(
process.env.GHOST_WEBHOOK_SECRET!,
req.body,
req.get('X-Ghost-Timestamp'),
req.get('X-Ghost-Signature-256'),
);
if (!ok) return res.sendStatus(401);
res.sendStatus(204);
handle(req.get('X-Ghost-Event'), JSON.parse(req.body.toString('utf8')));
});timingSafeEqual throws when the two buffers differ in length, so compare the lengths first.
Python
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 5 * 60
def verify_ghost_webhook(secret: str, raw_body: bytes, timestamp: str | None, signature: str | None) -> bool:
if not timestamp or not signature:
return False
try:
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
except ValueError:
return False
digest = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(f"sha256={digest}", signature)In Flask the raw body is request.get_data(); in Django it is request.body.
Go
func VerifyGhostWebhook(secret, rawBody []byte, timestamp, signature string) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(time.Since(time.Unix(ts, 0)).Seconds()) > 5*60 {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}Receivers written for GitHub
A receiver or library that already checks GitHub's X-Hub-Signature-256 works unchanged with the same secret. That signature covers the body only, so it gives no replay protection: prefer X-Ghost-Signature-256 when you write the check yourself.
Replacing the secret
"Replace secret" on the webhook's page, or POST …/webhooks/{webhookId}/secret, makes a new secret and shows it once. The old secret stops working at once: every delivery after that, retries included, is signed with the new one. There is no overlap, so update the receiver right away; a delivery that fails in between is retried, and it can also be redelivered from the webhook's page.