Verify Zoom Webhooks using a CloudFlare Worker

I am trying to implement the webhook validation here: Using webhooks in a cloudflare worker.

I have replicated the code in the example using the subtle crypto library built into CF Workers.

When I generate the hashed signature, the string generated does not match the signature in the header.

My CF Worker code is below:

async function handleRequest(request) {
    const req = await request.json()
    const ZOOM_WEBHOOK_SECRET_TOKEN = 'XXXXXXXXXXXXXXXXXXXXXX'

	// https://marketplace.zoom.us/docs/api-reference/webhook-reference/#verify-webhook-events
	let requestHeaders = Object.fromEntries(request.headers)
	let response

	const message = `v0:${requestHeaders['x-zm-request-timestamp']}:${JSON.stringify(req)}`

	const hashForVerify = await digestMessage(message)

	const signature = `v0=${hashForVerify}`

	console.log(req)
	console.log(requestHeaders['x-zm-signature'])
	console.log(signature)

	if (requestHeaders['x-zm-signature'] === signature) {

		// Webhook request came from Zoom

		// business logic here, example make API request to Zoom or 3rd party

		response = {
			message: 'Authorized request',
			status: 200
		}
	} else {
		// Webhook request did not come from Zoom
		response = {
			message: 'Request not authorized',
			status: 401
		}
	}

	// return the reponse (authorized or not)
	console.log(response)
	return new Response(response.message, {
		status: response.status
	})
}

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;
}

Anyone else successfully done this in a worker? Is there anywhere I’ve not correctly implemented the verification algorithm?

Also posted here: https://community.cloudflare.com/t/verify-zoom-webhook-in-worker/425280/2

Hi @MattB
Thanks for reaching out to the Zoom Developer Forum, I am happy to help here!
I have looked into the code snippet you have shared with us and it looks like when you are constructing the message, you are passing the entire request :slight_smile:

const message = `v0:${request.headers['x-zm-request-timestamp']}:${JSON.stringify(req)}`

Could you please try passing the req.body like so:

const message = `v0:${request.headers['x-zm-request-timestamp']}:${JSON.stringify(req.body)}`

Let me know if this helps,
Best,
Elisa

Hi @elisa.zoom

Thank you for getting back to me. I’m not passing the whole request, as

const req = await request.json()

returns the request body as JSON. I’ve also tried

const req = await request.text()

and then

const message = `v0:${request.headers['x-zm-request-timestamp']}:${req}`

I can confirm both pass a text string of the request body that parse as valid JSON. Neither work.

I’ve noted two things.

  1. Using webhooks example shows the {WEBHOOK_REQUEST_BODY} is not inclused in curly braces as expected of a JSON string and is messing the ‘event’ element from the sample data provided in the previous section. I coded this in (both just removing the outer braces, removing the outer braces + the event element and just removing the event element) and it still doesn’t produce a matching signature.

  2. The sample webhook app has been updated to bypass this verfication, 2 months ago. (See updated code to pass crc validation · zoom/webhook-sample-node.js@7c1689b · GitHub - interestingly the commit message calls this validation, I understand this to be webhook verification though)
    This has been left commented out. Why is this being bypassed in the sample app? Is there an issue with the signature generated by Zoom in the webhook?

Hi @MattB

  1. The sample webhook app has been updated to bypass this verfication, 2 months ago. (See updated code to pass crc validation · zoom/webhook-sample-node.js@7c1689b · GitHub - interestingly the commit message calls this validation, I understand this to be webhook verification though)
    This has been left commented out. Why is this being bypassed in the sample app? Is there an issue with the signature generated by Zoom in the webhook?

Thanks for bringing this to our attention, I will make sure to reach out to the team so we can fix this. I do not know why it has been commented out but you need to validate using the signature.

I have been trying to debug this issue on my end and I always see the request body in curly braces { }, I am always getting something like this:

{
  event: 'meeting.deleted',
  payload: {
    account_id: 'lKw',
    operator: 'elisa@gmail.com',
    operator_id: '6Gtdh2JlFW6vLFw',
    operation: 'single',
    object: {
      uuid: 'EwNQRGO+B4h68w0vg==',
      id: 837778,
      host_id: '6GtdheZQSq-l2JlFw',
      topic: 'Testing via API',
      type: 8,
      start_time: '2022-10-17T11:37:00Z',
      duration: 60,
      timezone: 'America/Mexico_City',
      occurrences: [Array]
    }
  },
  event_ts: 1665762910901
}

And I just ran the sample app that you shared

And what I did is that I commented out lines 24, 26, 29, 32 and 33 and it works just fine and I made sure to get ride of the validation of the URL:

  var response

  console.log(req.body)
  console.log(req.headers)

  // construct the message string
   const message = `v0:${req.headers['x-zm-request-timestamp']}:${JSON.stringify(req.body)}`

   const hashForVerify = crypto.createHmac('sha256', process.env.ZOOM_WEBHOOK_SECRET_TOKEN).update(message).digest('hex')

  // hash the message string with your Webhook Secret Token and prepend the version semantic
  const signature = `v0=${hashForVerify}`

  // you validating the request came from Zoom https://marketplace.zoom.us/docs/api-reference/webhook-reference#notification-structure
   if (req.headers['x-zm-signature'] === signature) {
    console.log(signature)

    // Zoom validating you control the webhook endpoint https://marketplace.zoom.us/docs/api-reference/webhook-reference#validate-webhook-endpoint
    // if(req.body.event === 'endpoint.url_validation') {
    //   const hashForValidate = crypto.createHmac('sha256', process.env.ZOOM_WEBHOOK_SECRET_TOKEN).update(req.body.payload.plainToken).digest('hex')

    //   response = {
    //     message: {
    //       plainToken: req.body.payload.plainToken,
    //       encryptedToken: hashForValidate
    //     },
    //     status: 200
    //   }

    //   console.log(response.message)

    //   res.status(response.status)
    //   res.json(response.message)

Please note how in line 24 we are passing req.body and you are passing the entire requests.
Hope this helps,
Elisa

Elisa - as previously stated, I am NOT passing the entire request. I am passing the request body exactly as you are. const req = await request,json() gets the body in JSON format into the const req. I can change the const name from ‘req’ to ‘body’ if you prefer.

I think the issue then is that on a cloudflare worker I cannot use nodeJS crypto. I am using the subtle crypto equivalent but must be missing something. As far as I can see I am doing the same, constructing a digest and converting it to hex for comparison (see SubtleCrypto.digest() - Web APIs | MDN)

The JSON Sample you provided is not validating properly. It should have double quotes instead of single quotes and identifiers should be quoted. Like this:

{
   "event":"meeting.deleted",
   "payload":{
      "account_id":"lKw",
      "operator":"elisa@gmail.com",
      "operator_id":"6Gtdh2JlFW6vLFw",
      "operation":"single",
      "object":{
         "uuid":"EwNQRGO+B4h68w0vg==",
         "id":837778,
         "host_id":"6GtdheZQSq-l2JlFw",
         "topic":"Testing via API",
         "type":8,
         "start_time":"2022-10-17T11:37:00Z",
         "duration":60,
         "timezone":"America/Mexico_City",
         "occurrences":[
            "Array"
         ]
      }
   },
   "event_ts":1665762910901
}

When I put the above JSON body through my worker I get a hashForVerify of v0=8d49d6cc06393da72b9fcf8e927fa6d6f1bb51f6d21d35551976d0eae348c75c

This assumes the x-zm-request-timestamp header is 1665762910901

As the worker would throw a 500 error if the JSON is not validate I’m assuming the Zoom Webhook does send valid JSON. Is all whitespace stripped out as this would change the hash?

Could you provide the exact request body you have run through your code, the value of the x-zm-request-timestamp header, and the signature for verification? I’ll run this through the worker and see if I’m getting the same signature or not.

Hi @MattB
I am so sorry for the late reply.
I was out of office for the last 2 weeks and I am only catching up into my messages.
Were you able to resolve this issue?