Verifying the events
Webhooks carry a certain level of security risk, as malicious actors can attempt to send fake webhooks, posing as legitimate services. This can lead to security vulnerabilities and other application issues.
To ensure the authenticity of received events from Yoco, the following two actions are required:
- Avoiding replay attacks
- Validating the signature
Avoiding replay attacks
Inside the webhook header there is an additional field webhook-timestamp
to indicate the Unix timestamp of when the webhook was signed.
Use this timestamp to drastically reduce the chance of a replay attack.
Because the timestamp is included in the header, the same header cannot be sent with a different timestamp without causing an invalid signature. When verifying the signature, you should also validate that the timestamp is within an acceptable threshold from your current system time (in Unix epoch format).
We recommend a threshold of up to 3 minutes. If Yoco retries sending the webhook notification event, the timestamp at the time of sending the retried event is used, so each attempt would have a new timestamp and therefore a new hash.
Validating the signature
Events sent from our service contain the webhook-signature
header.
To ensure the webhook's authenticity and confirm its origin from our service, follow the provided steps to compare a generated signature with the signature from the header.
Steps
1. Construct the signed content
To generate the signed content, concatenate the values of the webhook-id
header, webhook-timestamp
header, and the raw request body using a full stop character as a separator.
- Node.js
- Python
// Using Express
const headers = req.headers;
const requestBody = req.rawBody;
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signedContent = `${id}.${timestamp}.${requestBody}`;
# Using Flask
headers = request.headers
request_body = request.get_data(as_text=True)
id = headers.get('webhook-id')
timestamp = headers.get('webhook-timestamp')
signed_content = str(id) + '.' + str(timestamp) + '.' + str(request_body)
Make sure to use the raw request body in this step. Any minor modification to the body will generate a completely different signature.
2. Determine the expected signature
To calculate the expected signature, use the secret key and the signed content to generate a HMAC
SHA256
signature.
Then, encode this value using base64
encoding.
- Node.js
- Python
const crypto = require('crypto');
const secret = 'whsec_M0U0MDI3QjYzMEQ0NTK5NDNCIjVFMENCMDEzNzc1QkE=';
const secretBytes = new Buffer(secret.split('_')[1], "base64");
const expectedSignature = crypto
.createHmac('sha256', secretBytes)
.update(signedContent)
.digest('base64');
secret = 'whsec_M0U0MDI3QjYzMEQ0NTK5NDNCIjVFMENCMDEzNzc1QkE='
secret_bytes = base64.b64decode(secret.split('_')[1])
hmac_signature = hmac.new(secret_bytes, signed_content.encode(), hashlib.sha256).digest()
expected_signature = base64.b64encode(hmac_signature).decode()
Before calculating the expected signature, ensure that you exclude the whsec_ prefix from the secret key.
3. Compare signatures
The webhook-signature
header consists of a list of signatures and their version identifiers, separated by spaces.
Typically, the signature list consists of only one element, but it can contain any number of signatures.
For example:
v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= v1,bm9ldHUjKzFob2VudXRob2VodWUzMjRvdWVvdW9ldQo= v2,MzJsNDk4MzI0K2VvdSMjMTEjQEBAQDEyMzMzMzEyMwo=
Before verifying the signature, make sure to remove the version prefix and delimiter (e.g., v1,).
The generated signature should be compared with the signature sent in the webhook-signature
header.
If the generated signature matches the provided signature, you can proceed with processing the webhook event.
When comparing the signatures, it is recommended to use a constant-time string comparison method to prevent timing attacks.
Example
- Node.js
- Python
const express = require('express');
const crypto = require('crypto');
const getRawBody = require('raw-body');
const app = express();
app.use((req, res, next) => {
getRawBody(req, {
length: req.headers['content-length'],
encoding: 'utf-8'
}, (err, rawBody) => {
if (err) return next(err);
req.rawBody = rawBody;
next();
});
});
// Using Express
app.post("/my/webhook/url", function(req, res) {
const headers = req.headers;
const requestBody = req.rawBody;
// Construct the signed content
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signedContent = `${id}.${timestamp}.${requestBody}`;
// Determine the expected signature
const secret = 'whsec_M0U0MDI3QjYzMEQ0NTK5NDNCIjVFMENCMDEzNzc1QkE=';
const secretBytes = new Buffer(secret.split('_')[1], "base64");
const expectedSignature = crypto
.createHmac('sha256', secretBytes)
.update(signedContent)
.digest('base64');
// Compare the signatures
const signature = headers['webhook-signature'].split(' ')[0].split(',')[1]
if (crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(signature))) {
// process webhook event
return res.send(200);
}
// do not process webhook event
return res.send(403);
});
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
from flask import Flask, request
import hashlib
import base64
import hmac
app = Flask(__name__)
# Using Flask
@app.route('/my/webhook/url', methods=['POST'])
def webhook():
headers = request.headers
request_body = request.get_data(as_text=True)
# Construct the signed content
id = headers.get('webhook-id')
timestamp = headers.get('webhook-timestamp')
signed_content = str(id) + '.' + str(timestamp) + '.' + str(request_body)
# Determine the expected signature
secret = 'whsec_M0U0MDI3QjYzMEQ0NTK5NDNCIjVFMENCMDEzNzc1QkE='
secret_bytes = base64.b64decode(secret.split('_')[1])
hmac_signature = hmac.new(secret_bytes, signed_content.encode(), hashlib.sha256).digest()
expected_signature = base64.b64encode(hmac_signature).decode()
# Compare the signatures
signature = headers.get('webhook-signature').split(' ')[0].split(',')[1]
if hmac.compare_digest(signature, expected_signature):
# process webhook event
return "", 200
# do not process webhook event
return "", 403
if __name__ == '__main__':
app.run()