Issue Title: Unable to Verify Ghost Event Using Webhook

Issue Summary

Issue Title: Unable to Verify Ghost Event Using Webhook

Problem:

I am currently working on integrating webhooks from my Ghost website into my server, and I’m having trouble understanding how to verify that the events received by my server are indeed from my Ghost website and not from a potential malicious source.

Context:

I have set up webhooks in my Ghost website to notify my server of certain events such as new post creation, but I’m not sure how to implement authentication or headers to ensure that the incoming events are legitimate.

Request:

I would greatly appreciate it if someone could provide guidance on how to properly verify the authenticity of Ghost events received via webhooks. It would be especially helpful if there are specific steps or code examples that can be shared to illustrate the process.

Steps to Reproduce

Create a webhook
Send an event

Ghost Version

Version: 5.67.0 Environment: Production Database: mysql8 Mail: SMTP

Node.js Version

Latest node version using alpine docker container

How did you install Ghost?

Docker

Database type

MySQL 8

Browser & OS version

Safari

When your server receives a webhook from Ghost, have it make an API call to get the information independently. In other words, use the webhook as a trigger, not a source of truth.

3 Likes

You can set a webhook secret. Here’s how the secret is generated (tldr: hmac of the request payload signed with the shared secret):

3 Likes

I see that secret is part of the webhook model as a string with length 191,

But the docs don’t how say how to set it and it’s not in the admin API that I see.

How do you set a secret for a webhook, or discover one if one is set automatically?

You should be able to configure the secret as part of the webhook creation process for the integration:

edit: this requires enabling developer experiments (source) for admin settings, but not for the admin-x settings (source)

2 Likes

Thanks. Looks the secret field could use different placeholder text.

1 Like

Huge thanks, it was exactly what I was looking for.

1 Like

How can this be enabled in the hosted Ghost Pro?

I don’t think it’s possible, but you can reach out to Ghost support for confirmation.

Can you please provide clarification or documentation on how to verify it. This information is non existent in the official documentation and super confusing.

You’ll need to perform the same operation that was performed in the linked line of code and compare the stripped* x-ghost-signature value with your locally computed value

*e.g.

const [externalHmac, timestamp] = header.split(',');
if (seenTimestamps.has(timestamp)) {
  return false;
}
// You might also want to make sure the timestamp is within the last X period (like 3 mins)

if (hash(...) !== exteralHmac) {
  return false;
}
return true;
1 Like

Thanks, but this is still not very helpful. Can we get some proper documentation with a full working example please?

I’m just a member of the Ghost community so I can’t update the documentation. I don’t have the bandwidth to provide the exact solution, so I’ve provided a starting point and high-level code example.

1 Like

I haven’t seen a proper documentation about it either, but here’s hopefully a better example for those who stumble upon this post. It would also be better to verify the information on a second hand as mentioned by Cathy.

const signature = request.headers['x-ghost-signature'];

if (!signature || typeof signature !== 'string') {
	logger.error(
		`Request from ${request.ip} to ${request.originalUrl} denied, missing or invalid x-ghost-signature header`,
		GhostSecretKeyGuard.name,
	);
	throw new UnauthorizedException();
}

const [ghostHmac, timestampPart] = signature.split(',');

if (!ghostHmac || !timestampPart) {
	logger.error(
		`Request from ${request.ip} to ${request.originalUrl} denied, missing parts in x-ghost-signature header`,
		GhostSecretKeyGuard.name,
	);
	throw new UnauthorizedException();
}

const timestamp = parseInt(timestampPart, 10);
const currentTimestamp = Date.now();
const timestampDiff = Math.abs(currentTimestamp - timestamp);

// Weak check for replay attack
if (isNaN(timestampDiff) || timestampDiff > MAX_TIMESTAMP_DIFF /* Ex: 5000 ms */) {
	logger.error(
		`Request from ${request.ip} to ${request.originalUrl} denied, invalid timestamp in x-ghost-signature header`,
		GhostSecretKeyGuard.name,
	);
	throw new UnauthorizedException();
}

const hmac = crypto
	.createHmac('sha256', config.api.ghost.secret /* Secret used for the webhook */)
	.update(JSON.stringify(request.body))
	.digest('hex');

if (ghostHmac !== `sha256=${hmac}`) {
	logger.error(
		`Request from ${request.ip} to ${request.originalUrl} denied, invalid ghost signature`,
		GhostSecretKeyGuard.name,
	);
	throw new UnauthorizedException();
}

return true;
2 Likes

For anyone implementing this after July 2024, the signature is now hashed with the timestamp.

headers['X-Ghost-Signature'] = `sha256=${crypto.createHmac('sha256', secret).update(`${reqPayload}${ts}`).digest('hex')}, t=${ts}`

Source Code
Commit

2 Likes

This still doesn’t work for me. If I try to compute the hash using the following code then it is still different from the value from in the x-ghost-signature header:

  const hmac = `sha256=${crypto
    .createHmac("sha256", secret)
    .update(`${request.body}${timestamp}`)
    .digest("hex")}`;

I’m using Node 22 and crypto 1.0.1

I also tried .update(JSON.stringify(request.body)), as in @unknown 's answer

What exactly does the secret need to be hashed with? The body or the whole request?

In the end it was probably the way the request body was being built on my end.

I used .update(${(await request.json())}${timestamp}) and it worked for me

1 Like

I spent 2 days to understand why my webhooks stopped working and then realized that webhook signature format is changed 6 months ago with a “patch” release. (Release 5.87.1 · TryGhost/Ghost · GitHub)

This is very concerning. As the commit message already mentions, this is a BREAKING CHANGE, how can it be released with a patch release, and just 1 line of text without mentioning any risks of it?

I hope this will never happen again and Ghost team will stick to the semantic versioning rules. Otherwise what is the meaning of having all those numbers in the versions, right? Please. :heart:

2 Likes