🏠 Home

Verify HMAC signature for Paddle Webhooks in Deno

2024, March 12


Preface

  • Paddle is a Payment Service Provider of the Merchant of Record variety.

    • Paddle makes it really easy to sell online while staying tax-compliant.
    • Paddle pays VAT tax for all of your sales depending on the specific tax requirements on the country of the customer. You choose if your pricing is VAT-inclusive or VAT-exclusive.
    • Paddle pricing is 5% + 50 cents per transaction at time of writing this article.
    • Paddle only works with software products, if you need to sell physical goods then you are better off going with Stripe.
  • Deno

    • Deno is a new alternative to NodeJS
    • Deno is awesome!
    • Deno implements the JavaScript Web API (Web Standards) instead of relying on a special-case standard library as Node does. This helps developers learn one API that works in the browser as well as the server, this also means that you can very easily use the same code for your server as you do for the browser.
  • Paddle Webhooks

    • Paddle offers Webhooks to communicate with our server.
    • We want to consume these webhooks for a variety of reasons, such as taking note whenever a purchase is processed by Paddle and getting the relevant customer information when it happens.
    • To receive the HTTP request in our server for one of these webhooks, we must verify that the request comes from Paddle because we want to protect our endpoint.
    • If you are using NodeJS then prefer to use the Paddle SDK for NodeJS, this is very new (Jan 2024) and it wasn't available at time of writing. Deno recently got support to import NPM modules but we rather avoid it if we can!
  • It is contrived to figure out how to verify the HMAC signature from Paddle in Deno.

    • The Paddle documentation isn't very clear on everything that needs to be done.

    • The Web Standard crypto API can be dense to understand.

Verify HMAC signature for Paddle Webhooks in Deno

First, make sure you read the Paddle docs.

The following code example includes a handler from the Fresh framework, a web framework for full-stack development in Deno. Fresh allows us to write code that runs on the backend and the frontend at the same time, so you want to be careful here and make sure that this code is only ran for the backend. We don't want to leak any secret keys to the client.

Without further ado.

Here is the handler for the endpoint to which Paddle will send us information. This is a standard Fresh handler. The function that validates our HMAC is called validatePaddleSignature:

export const handler: Handlers = {
  async POST(req: Request, _ctx: HandlerContext) {
    const paddleSignature = req.headers.get("Paddle-Signature");
    const rawBody = await req.clone().text();
    assert(await validatePaddleSignature(paddleSignature, rawBody));
    // it worked? ok we can parse and do all the rest
    const jsonBody = await req.json(); 

The validatePaddleSignature function will look like this:

async function validatePaddleSignature(
  paddleSignature: string | null,
  rawRequestBody: string | null,
): Promise<boolean> {
  // Paddle docs: https://developer.paddle.com/webhooks/signature-verification
  // The paddle signature is the string contained inside Paddle-Signature header.
  // This is a value that looks similar to this:
  //  ts=1671552777;h1=eb4d0dc8853be92b7
  assert(paddleSignature, "Paddle-Signature cannot be empty");
  assert(rawRequestBody, "Request body cannot be empty");
  const [ts, h1] = paddleSignature.split(";");
  const [_ts, tsValue] = ts.split("=");
  const [_h1, paddleHmac] = h1.split("=");
  // generate the payload the same way paddle does
  const payload = `${tsValue}:${rawRequestBody}`;
  const key = await webhookSecretEnv();
  // Fun riddle: Paddle signature is hex encoded, but they never mention it in their docs!
  const hmacSignature = await hmacHexSignature(key, payload);
  assert(
    timingSafeEqual(
      new TextEncoder().encode(hmacSignature),
      new TextEncoder().encode(paddleHmac),
    ),
    "HMAC signature for Paddle notificatoin has failed",
  );
  return true;
}
  • The string.split functions are breaking down the header, as the paddle docs mention.
  • We need to reconstruct the payload the same way paddle does before signing it, we will sign this payload with the secret key we got from Paddle.
  • This key is secret and should only be known by Paddle and our server.
  • This is unlike a Public-Private key-pair where we would get a public key from the server that sends the message and it wouldn't matter if we leaked it.
  • The whole process:
    • First we reconstruct the payload and then sign it with the secret key to get the HMAC signature ourselves
    • Then we compare the signature that we generated with the one that Paddle sent us alongside the request, the paddleHmac in the code above.
    • If both match, the message is verified.

In the code above we are using webhookSecretEnv to get the secret key, for your convenience here is one way of getting ENV vars in Deno:

// where we have this in deno.json:

//   "imports": {
//     "$std/": "https://deno.land/std@0.204.0/",

import { crypto } from "$std/crypto/mod.ts";


async function webhookSecretEnv(): Promise<CryptoKey> {
  const PaddleWebhookSecretVar = "PADDLE_NOTIFICATIONS_WEBHOOK_SECRET";
  const webhookSecret = Deno.env.get(PaddleWebhookSecretVar);
  assert(
    webhookSecret,
    `You forgot to add ${PaddleWebhookSecretVar} environment variable`,
  );
  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw",
    encoder.encode(webhookSecret),
    { name: "HMAC", hash: "SHA-256" },
    true,
    ["sign", "verify"],
  );
  return key;
}

An important aspect is the usage of crypto.subtle.importKey from the SubtleCrypto API:

  • We use TextEncoder because we need a Uint8Array from our string.
  • importKey gives us a CryptoKey object so that we can use it as a key to other Crypto functions.
  • We know to use SHA-256 from Paddle Docs.

Cool! We got the payload, we got the key, now we need to do the signing. This is where it gets tricky.

hmacHexSignature is our function to create the signature that we need. Lets look at it:

// Generates the HMAC signature for a given payload
export async function hmacHexSignature(key: CryptoKey, payload: string): Promise< string > {
 const signedArray = await crypto.subtle.sign(
    { name: "HMAC", hash: "SHA-256" },
    key,
    new TextEncoder().encode(payload),
  );
  return arrayBufferToHex(signedArray)
}

  • Really simple!
  • But wait... What's that arrayBufferToHex doing here?
  • Unbeknownst to us, the signed payload signedArray isn't simply converted to a string with TextEncoder as I would reasonably expect. We do convert it to a string first, but then the string is Hex encoded. We must Hex-encode our signature because Paddle hex-encodes their signature and we want to compare both signatures to get a positive match in values.
  • I don't know why Paddle documents would elide this very important information in their "Verify manually" instructions. I was lucky to find another blog that figured how to do it first in Ruby, and I noticed that they were hex encoding the signature. Link to the other blog.
  • However, in Ruby it seems to be much simpler because the ruby SSL library already includes a function to get the signature in hex format:
    hmac = OpenSSL::HMAC.hexdigest(digest, key, data)
    
    So apparently it is common practice to use hex encoding, however "common practice" shouldn't be reason enough to elide information that is not part of a cryptographic standard.
  • So we need to construct our own hex encoder. Luckily the practice is common enough that some examples in MDN documentation include a JavaScript function that does exactly this.

From one of the MDN pages about the SubtleCrypto module:

// verbatim from MDN:
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string

const text =
  "An obscure body in the S-K System, your majesty. The inhabitants refer to it as the planet Earth.";

async function digestMessage(message) {
  const msgUint8 = new TextEncoder().encode(message); // encode as (utf-8) Uint8Array
  const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8); // hash the message
  const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array
  const hashHex = hashArray
    .map((b) => b.toString(16).padStart(2, "0"))
    .join(""); // convert bytes to hex string
  return hashHex;
}

digestMessage(text).then((digestHex) => console.log(digestHex));

Awesome! So lets put it into practice and finish our hmacHexSignature function:

// Hashes a message with SHA-256 and returns its HEX representation
// Code taken from:
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string
export async function hashMessageToHex(message: string, algorithm: "SHA-256" | "SHA-384" | "SHA-512"): Promise<string> {
  const msgUint8 = new TextEncoder().encode(message); // encode as (utf-8) Uint8Array
  const hashBuffer = await crypto.subtle.digest(algorithm, msgUint8); // hash the message
  return arrayBufferToHex(hashBuffer)
}

function arrayBufferToHex(hashBuffer: ArrayBuffer): string {
  const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array
  const hashHex = hashArray
    .map((b) => b.toString(16).padStart(2, "0"))
    .join(""); // convert bytes to hex string
  return hashHex;
}

Yeah! Lets revisit our initial function... We can see that:

  • We got the key and the payload to create our own hmacSignature
  • We got the signature that Paddle sent us in the request: paddleHmac
  • Now we just need to compare the two. To be extra-safe we want to avoid using a typical == or === comparison. We will use timingSafeEqual from Deno STD to protect from timing-attacks. Right now timingSafeEqual is not yet in the WebCrypto Web API, so Deno STD provides us with their own implementation.
  const hmacSignature = await hmacHexSignature(key, payload);
  assert(
    timingSafeEqual(
      new TextEncoder().encode(hmacSignature),
      new TextEncoder().encode(paddleHmac),
    ),
    "HMAC signature for Paddle notificatoin has failed",
  );
  return true;

:D there you go, you are welcome.

Credits


Subscribe to my mailing list

Only to update you when I submit a new blog post.


Go back to the top
© Copyright 2023 by Jose.