Attackers can impersonate services by simply sending a fake webhook to an endpoint since it's fundamentally an HTTP POST from an unknown source.
To prevent this, every webhook message (along with its payload) is signed with a unique key (webhook_secret
) . Clients can use the webhook_secret
to verify the message origin.
Clients can provide a custom webhook_secret
when creating a Webhook. If not provided, a randomly generated webhook_secret
will be generated.
User provided webhook_secret will be stored in base64 encoding
How to verify webhooks
Each webhook message contains 3 important pieces of information in the header, which can be utilized for verification:
webhook-id
- The unique message identifier for the webhook message. This identifier is unique across all messages, but will be the same when the same webhook is being resent (e.g. due to failure).webhook-timestamp
- Timestamp in seconds since epochwebhook-signature
- Base64 encoded list of signatures (space delimited)
Signed Content
The signed content is composed by joining the webhook_id
, webhook_timestamp
and message body
with a period. In Node.js, it will look something like this:
signedContent = `${webhook_id}.${webhook_timestamp}.${body}`;
body
- raw body of the request.
You need to use the raw request body when verifying webhooks, as the cryptographic signature is sensitive to even the slightest changes. You should watch out for frameworks that parse the request as JSON and then stringify it because this too will break the signature verification.
Validating Signed Content against Expected Signature
The webhook-signature
is generated using an HMAC with SHA-256
To calculate the expected signature:
HMAC
thesigned_content
from above using yourwebhook_secret
(e.g:MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
)- The generated signature should match one of the signatures in
webhook-signature
. Thewebhook-signature
header is composed of a list of space delimited signatures and their corresponding version identifiers. For example:v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= v1,bm9ldHUjKzFob2VudXRob2VodWUzMjRvdWVvdW9ldQo= v2,MzJsNDk4MzI0K2VvdSMjMTEjQEBAQDEyMzMzMzEyMwo=
- There's usually a single
webhook-signature
but there could be any number of signatures for versioning andwebhook_secret
rotation.
The following code sample calculates the expected signature in Node.js
const crypto = require("crypto");
// HTTP Request headers
const webhook_id = "msg_2Kp7XXfVpg9DcEphTNjt7QunxcZ";
const webhook_timestamp = "1674659710";
const webhook_signature = "v1,F3NpVu//Inc0BgwSiBu7fLr+eC7tC2XQxbjwFhhKfAI=";
// HTTP body (JSON.stringify here is used as an example. You should use the raw request body since stringify will break signature verification)
const body = JSON.stringify({
data: [
{
collection_id: "c4ae7e3f9a4ca6a1e61b57dd16ff3084",
floor_price: {
marketplace_id: "opensea",
payment_token: {
address: null,
decimals: 18,
name: "Ether",
payment_token_id: "ethereum.native",
symbol: "ETH",
},
value: 2900000000000000000,
},
},
],
type: "collection.floor_price_update",
});
signedContent = `${webhook_id}.${webhook_timestamp}.${body}`;
const secret = "<YOUR webhook_secret here>"
// Need to base64 decode the secret
const secretBytes = Buffer.from(secret, "base64");
const computed_signature = crypto
.createHmac("sha256", secretBytes)
.update(signedContent)
.digest("base64");
console.log(computed_signature);
// computed_signature should match one of the signatures in webhook_signature
Use the Standard Webhooks tool to help debug expected signature of a webhook message