Implementing Webhook HMAC verification in Express.JS
Table of Contents
Sample code for those in a hurry
https://replit.com/@emlpayments/EMLWebhooksSampleNode
Overview
You can receive webhook notifications via EML's webhook notification API.
To verify that this is a genuine call from EML, you can inspect the Authorization header, which will be in the from
Authorization HMAC_SHA256 <sample_token_id>;<computed_hmac>
The included example shows the correct way of computing the hmac using the verify
option.
Fully worked example
When defining the route for the webhook, pass a callback to the verify
option, and validate the HMAC using the buffer.
const { HmacVerify } = require('./hmac');
app.post('/webhook',
express.json({ verify: HmacVerify(getSecretFromTokenId) }),
(request, response) => {
...
}
HmacVerify
is a custom function provided by the hmac.js library
You will need to provided a function getSecretFromTokenId(hmac_key_id)
that turns a hmac_key_id into a hmac_key_secret as shown in Register a web hook
Here is a fully worked example
# index.js
const express = require('express');
const { HmacVerify } = require('./hmac');
const app = express();
const port = 3000;
/**
This is a sample function only. In practice, you should
store pairs of hmac_key_id and hmac_key_secret in a
database, and register these with the webhooks /post endpoint
**/
function getSecretFromTokenId(hmac_key_id) {
switch (hmac_key_id) {
case "token-1":
return "123456789";
case "token-2":
return "7584978954";
default:
throw new Error(`HMAC Key Id ${hmac_key_id} not found`);
}
}
app.post('/webhook',
express.json({ verify: HmacVerify(getSecretFromTokenId) }),
(request, response) => {
response.send(JSON.stringify(request.body) + "\n");
response.end();
});
app.listen(port, () => {
const appUrl = "http://localhost:${port}/webhook";
console.log(`Webhook listening at ${appUrl}
test with:
# pass
curl -d \'{"account_id":"Z8ZKW0TMA"}\' -H 'Authorization: HMAC_SHA256 token-1;e3637b4bb1529b4fef0c238938170139214f9e9ee5f5c7e020fcb373a0f76be8' -H 'Content-Type: application/json' ${appUrl}
# fail
curl -d \'{"account_id":"Z8ZKW0TMA" }\' -H 'Authorization: HMAC_SHA256 token-1;e3637b4bb1529b4fef0c238938170139214f9e9ee5f5c7e020fcb373a0f76be8' -H 'Content-Type: application/json' ${appUrl}
curl -d \'{"account_id":"Z8ZKW0TMA"}\' -H 'Authorization: HMAC_SHA256 token-1;1111111111111111111111111111111111111111111111111111111111111111' -H 'Content-Type: application/json' ${appUrl}`);
});
# hmac.js
const crypto = require('crypto');
function HmacVerify(getSecretFromTokenId) {
const pattern = /^HMAC_SHA256 (?<tokenId>.+);(?<hmac>.+)$/;
return function (request, response, buffer, encoding) {
const header = request.header('Authorization');
if (!header) {
throw new Error('Authorization header not found');
}
const match = header.match(pattern);
if (!match) {
throw new Error('Authorization HMAC_SHA256 not found');
}
const secret = getSecretFromTokenId(match.groups.tokenId);
if (!secret) {
throw new Error(`Token Id ${match.groups.tokenId} not found`);
}
const hmac = computeHmac(buffer, secret);
if (hmac.toUpperCase() != match.groups.hmac.toUpperCase()) {
var message = `HMAC_SHA256 computeHmac did not match
> TOKEN_ID='${match.groups.tokenId}'
> SECRET='${secret}'
> HMAC_COMPUTED='${hmac}'
> HMAC_RECEIVED='${match.groups.hmac}'
> BUFFER='${buffer.toString('hex')}'
> ENCODING='${encoding}'
> let { computeHmac } = require('./hmac');
> console.log(Buffer.from(BUFFER,'hex').toString('utf-8'))
${Buffer.from(buffer,'hex').toString('utf-8')}
> console.log(computeHmac(Buffer.from(BUFFER,'hex'),SECRET));
${computeHmac(Buffer.from(buffer,'hex'),secret)}
`
throw new Error(message);
}
return true;
}
}
function computeHmac(buffer, salt) {
if (!buffer || !salt) {
return null;
}
return crypto.createHmac('sha256', Buffer.from(salt, 'hex')).update(buffer).digest('hex');
}
module.exports = { computeHmac, HmacVerify }
Note
You should especially avoid using JSON.stringify
to compute hmac, as there is no guarantee it will roundtrip exactly byte-for-byte to the original data.
# JSON.stringify does not always round trip!
var data1 = '{ "company" : "EML Payments" }';
var data2 = JSON.stringify( JSON.parse( data1 ));
console.log("data1 == data2 ? " + (data1 == data2));
# false