Ghost docs
Webhooks

Verifying deliveries

Check that a delivery came from Ghost, with examples in Node.js, Python and Go

Check X-Ghost-Signature-256:

  1. 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.
  2. Read X-Ghost-Timestamp and 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.
  3. Compute HMAC-SHA256 with the signing secret as the key over the timestamp, a ., and the raw body. Hex-encode it and put sha256= in front.
  4. 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.
  5. 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.

On this page