Implementing Webhook HMAC verification in Express.JS

Table of Contents

Sample code for those in a hurry

https://replit.com/@emlpayments/EMLWebhooksSampleNodeopen in new window

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