Is there any way to add custom header to webhooks?

Hi there,

I am using ghostjs → integrations → custom integration → webhooks for listening to events fired from my blog. Webhooks point towards my another server via public http api. The problem is, anybody can fake webhook fired from ghostjs, how can I add authentication to my webhook? It would be great if Ghostjs allowed adding custom headers to ( x-auth etc. ) secure my public endpoint

1 Like

Newer versions of Ghost will send the X-Ghost-Signature header if you configure a webhook secret in Ghost Admin.

Here’s how the header value (nodejs hmac logic, but you should be able to adapt it for other languages): sha256=${crypto.createHmac('sha256', secret).update(reqPayload).digest('hex')}, t=${Date.now()}

3 Likes

It may be useful to someone, as I have implemented it for WordPress REST API (Don’t forget to change ghost-webhook-secret-key to your webhook secret key):

    public function verify_ghost_signature( $request ): bool|WP_Error {
		$signature_header = $request->get_header( 'x-ghost-signature' );
		$payload          = $request->get_body();
		$secret_key       = 'ghost-webhook-secret-key';

		preg_match( '/sha256=([a-f0-9]+), t=(\d+)/', $signature_header, $matches );
		if ( ! $matches || count( $matches ) !== 3 ) {
			return new WP_Error( 'invalid_signature', 'Invalid signature format.', [ 'status' => 403 ] );
		}

		$provided_signature = $matches[1];
		$timestamp          = $matches[2];

		$current_timestamp = time() * 1000;
		$time_limit        = 5 * 60 * 1000;

		if ( abs( $current_timestamp - $timestamp ) > $time_limit ) {
			return new WP_Error( 'expired_signature', 'Signature timestamp expired.', [ 'status' => 403 ] );
		}

		$data_to_sign = $payload . $timestamp;

		$generated_signature = hash_hmac( 'sha256', $data_to_sign, $secret_key );

		if ( ! hash_equals( $generated_signature, $provided_signature ) ) {
			return new WP_Error( 'invalid_signature', 'Signature verification failed.', [ 'status' => 403 ] );
		}

		return true;
	}
1 Like

If anyone using typescript or javascript can use this functions to verify their webhook secret :

app.use(
  bodyParser.json({
    verify: (req, res, buf) => {
      req.rawBody = buf.toString();
    },
    limit: "10mb",
  })
);

const verifySignature = async (req) => {
  try {
    const signature = req.headers["x-ghost-signature"];
    if (!signature) throw new Error("Missing signature");

    const signatureParts = signature.split(", ");
    const sha256Part = signatureParts.find((part) =>
      part.startsWith("sha256=")
    );
    const timestampPart = signatureParts.find((part) => part.startsWith("t="));

    if (!sha256Part || !timestampPart)
      throw new Error("Missing signature or timestamp");

    const receivedHash = sha256Part.split("=")[1];
    const timestamp = timestampPart.split("=")[1];

    const requestPayload = req.rawBody + timestamp;

    const computedHash = crypto
      .createHmac("sha256", WEBHOOK_SECRET)
      .update(requestPayload)
      .digest("hex");

    if (computedHash !== receivedHash)
      throw new Error("Signature verification failed");

    return true;
  } catch (error) {
    throw new Error(`Signature verification failed: ${error.message}`);
  }
};

As user @vikaspotluri123 explained perfectly, the secret configured in Ghost Admin is send into X-Ghost-Signature header.

I work with Next.js, this an example to get and verify the signature. I make changes in @vikaspotluri123 code:

// route.ts

import verifyGhostSignature from "@/utils/verifyGhostSignature";

export async function POST(request: Request) {
  const rawBody = await request.text();
  const jsonBody = JSON.parse(rawBody);

  const ghostSignature = await verifyGhostSignature(request, rawBody);

  if (ghostSignature) {
    ...
  }

  return Response.json("Error on Ghost Signature");
}

// verifyGhostSignature.ts

import { createHmac } from "node:crypto";

export default async function verifyGhostSignature(
  request: Request,
  rawBody: string,
) {
  try {
    const signature = request.headers.get("x-ghost-signature");

    if (!signature) throw new Error("Missing signature");

    const signatureParts = signature.split(", ");
    const sha256Part = signatureParts.find((part) =>
      part.startsWith("sha256="),
    );
    const timestampPart = signatureParts.find((part) => part.startsWith("t="));

    if (!sha256Part || !timestampPart)
      throw new Error("Missing signature or timestamp");

    const receivedHash = sha256Part.split("=")[1];
    const timestamp = timestampPart.split("=")[1];

    const requestPayload = rawBody + timestamp;

    const computedHash = createHmac(
      "sha256",
      process.env.GHOST_WEBHOOK_SECRET as string,
    )
    .update(requestPayload)
    .digest("hex");

    if (computedHash !== receivedHash)
      throw new Error("Signature verification failed");

      return true;
  } catch (error) {
    throw new Error(`Signature verification failed: ${error}`);
  }
}