Help with authenticating webhook signature

I am having some issues generating a signature that matches the x-sendbird-signature header of a webhook request. The webhook documentation is being used as reference. Hopefully another set of eyes can help resolve this.

Our service is written in Python 3.8 (Django) and we are using Django Rest Framework (DRF) to process the webhook requests.

Note, some of the contents of this post were redacted and the examples provided are from a testing application using mocked data.


Beeceptor is being used to capture webhook requests from our test application. The following is an example of a webhook request.

Headers
{
  "content-length": "1897",
  "accept-encoding": "gzip, deflate",
  "accept": "*/*",
  "user-agent": "SendBird",
  "x-signature": "16e3cb25eea601c7a211e5a52d91fa1a31afdd33cc2a2ac5f248ea45189efb26",
  "content-type": "application/json",
  "unicode-escaped": "true",
  "x-sendbird-signature": "16e3cb25eea601c7a211e5a52d91fa1a31afdd33cc2a2ac5f248ea45189efb26"
}
Response
{"category":"group_channel:message_send","sender":{"nickname":"john","user_id":"f6dc589f-bcdb-48d5-8848-d8d60e4cba5b","profile_url":"https:\/\/storyplace-stage.s3.amazonaws.com\/asset\/image\/3047f6c2-2d6e-4ab3-a049-07aef47cd170\/pixel_john.jpg","metadata":{"full_name":"John Baker"}},"silent":false,"custom_type":"","mention_type":"users","mentioned_users":[],"app_id":"9BDF228D-8E32-43DE-88B7-969BA4CF4C40","sender_ip_addr":"99.231.147.50","members":[{"is_blocking_sender":false,"unread_message_count":7,"total_unread_message_count":7,"is_active":true,"is_online":true,"is_hidden":0,"channel_mention_count":0,"nickname":"john","is_blocked_by_sender":false,"user_id":"f6dc589f-bcdb-48d5-8848-d8d60e4cba5b","channel_unread_message_count":2,"state":"joined","push_enabled":true,"push_trigger_option":true,"profile_url":"https:\/\/storyplace-stage.s3.amazonaws.com\/asset\/image\/3047f6c2-2d6e-4ab3-a049-07aef47cd170\/pixel_john.jpg","metadata":{"full_name":"John Baker"}},{"is_blocking_sender":false,"unread_message_count":8,"total_unread_message_count":8,"is_active":true,"is_online":true,"is_hidden":0,"channel_mention_count":0,"nickname":"youngastro","is_blocked_by_sender":false,"user_id":"6349077a-833a-4eda-bffd-8fc1381ae6f9","channel_unread_message_count":3,"state":"joined","push_enabled":true,"push_trigger_option":true,"profile_url":"https:\/\/storyplace-stage.s3.amazonaws.com\/asset\/image\/b88eff9a-7d01-43ef-8397-d8384c0eab5c\/earth.gif","metadata":{"full_name":"Young Astronaut"}}],"type":"MESG","payload":{"custom_type":"","created_at":1600368905025,"translations":{},"message":"g\n","data":"","message_id":7470735177},"channel":{"is_distinct":true,"name":"a6571743-7093-4d44-8ef9-32130833a930","custom_type":"private","is_ephemeral":false,"channel_url":"eb823739-c8c8-42ee-a078-5b53e47283b7","is_public":false,"is_super":false,"data":"","is_discoverable":false},"sdk":"JavaScript"}

DRF provides conveniences for handling requests, such as properties for the request byte array and parsed data, so the following script was written to eliminate any characteristics of the framework.

Source
import hashlib
import hmac

x_sendbird_signature = "16e3cb25eea601c7a211e5a52d91fa1a31afdd33cc2a2ac5f248ea45189efb26"
payload = '{"category":"group_channel:message_send","sender":{"nickname":"john","user_id":"f6dc589f-bcdb-48d5-8848-d8d60e4cba5b","profile_url":"https:\/\/storyplace-stage.s3.amazonaws.com\/asset\/image\/3047f6c2-2d6e-4ab3-a049-07aef47cd170\/pixel_john.jpg","metadata":{"full_name":"John Baker"}},"silent":false,"custom_type":"","mention_type":"users","mentioned_users":[],"app_id":"9BDF228D-8E32-43DE-88B7-969BA4CF4C40","sender_ip_addr":"99.231.147.50","members":[{"is_blocking_sender":false,"unread_message_count":7,"total_unread_message_count":7,"is_active":true,"is_online":true,"is_hidden":0,"channel_mention_count":0,"nickname":"john","is_blocked_by_sender":false,"user_id":"f6dc589f-bcdb-48d5-8848-d8d60e4cba5b","channel_unread_message_count":2,"state":"joined","push_enabled":true,"push_trigger_option":true,"profile_url":"https:\/\/storyplace-stage.s3.amazonaws.com\/asset\/image\/3047f6c2-2d6e-4ab3-a049-07aef47cd170\/pixel_john.jpg","metadata":{"full_name":"John Baker"}},{"is_blocking_sender":false,"unread_message_count":8,"total_unread_message_count":8,"is_active":true,"is_online":true,"is_hidden":0,"channel_mention_count":0,"nickname":"youngastro","is_blocked_by_sender":false,"user_id":"6349077a-833a-4eda-bffd-8fc1381ae6f9","channel_unread_message_count":3,"state":"joined","push_enabled":true,"push_trigger_option":true,"profile_url":"https:\/\/storyplace-stage.s3.amazonaws.com\/asset\/image\/b88eff9a-7d01-43ef-8397-d8384c0eab5c\/earth.gif","metadata":{"full_name":"Young Astronaut"}}],"type":"MESG","payload":{"custom_type":"","created_at":1600368905025,"translations":{},"message":"g\n","data":"","message_id":7470735177},"channel":{"is_distinct":true,"name":"a6571743-7093-4d44-8ef9-32130833a930","custom_type":"private","is_ephemeral":false,"channel_url":"eb823739-c8c8-42ee-a078-5b53e47283b7","is_public":false,"is_super":false,"data":"","is_discoverable":false},"sdk":"JavaScript"}'

signature = hmac.new(
    key=b'REDACTED',
    msg=bytes(payload.encode('utf8')),
    digestmod=hashlib.sha256,
).hexdigest()

print(signature, x_sendbird_signature)
assert (signature==x_sendbird_signature)
Output
/REDACTED/venv/bin/python "/REDACTED/sendbird_signature_test.py"
37d8f06f0ab8ee163c7d3f3810816f62d78b19fdcdcf5fab0ebadfaf8970bd2e 16e3cb25eea601c7a211e5a52d91fa1a31afdd33cc2a2ac5f248ea45189efb26
Traceback (most recent call last):
  File "/REDACTED/sendbird_signature_test.py", line 14, in <module>
    assert (signature==x_sendbird_signature)
AssertionError

The documentation is not clear which API token I should use. I have tried with the master and several others generated in the dashboard. It’s not clear which token the server uses to generated the hash. I don’t see any options to specify a token for the webhook. I even tried using the application ID.


I have tried several different ways of encoding the key and message, several different webhook requests, and different ways of interpreting the JSON response (such as encoding to Python dict, dumping back to a string, encoding, and casting to byte array). I also tried capturing a webhook example directly from our staging server, and another request interceptor service.

Any assistance would be greatly appreciated. Thank you.

@jhnbkr

Thank you so much for the detailed post. Please note that only the master API token can be used for authenticating x-sendbird-signature.

Other than that, your code looks okay. I did find that your sample decodes as expected in a node.js environment but not in Python3. I will ask our engineers to take a look.

@Jason thanks for the response. That’s good to know it has to be our master token. I may suggest adding that to the documentation.

I am still not seeing the expected results when using the master token in Python3. I did try a variant in node (with the master token) and the results did not match either.

Source

const crypto = require('crypto');

const SENDBIRD_API_TOKEN = 'REDACTED';
const body = '{"category":"group_channel:message_send","sender":{"nickname":"john","user_id":"f6dc589f-bcdb-48d5-8848-d8d60e4cba5b","profile_url":"https:\/\/storyplace-stage.s3.amazonaws.com\/asset\/image\/3047f6c2-2d6e-4ab3-a049-07aef47cd170\/pixel_john.jpg","metadata":{"full_name":"John Baker"}},"silent":false,"custom_type":"","mention_type":"users","mentioned_users":[],"app_id":"9BDF228D-8E32-43DE-88B7-969BA4CF4C40","sender_ip_addr":"99.231.147.50","members":[{"is_blocking_sender":false,"unread_message_count":11,"total_unread_message_count":11,"is_active":true,"is_online":false,"is_hidden":0,"channel_mention_count":0,"nickname":"youngastro","is_blocked_by_sender":false,"user_id":"6349077a-833a-4eda-bffd-8fc1381ae6f9","channel_unread_message_count":6,"state":"joined","push_enabled":true,"push_trigger_option":true,"profile_url":"https:\/\/storyplace-stage.s3.amazonaws.com\/asset\/image\/b88eff9a-7d01-43ef-8397-d8384c0eab5c\/earth.gif","metadata":{"full_name":"Young Astronaut"}},{"is_blocking_sender":false,"unread_message_count":7,"total_unread_message_count":7,"is_active":true,"is_online":true,"is_hidden":0,"channel_mention_count":0,"nickname":"john","is_blocked_by_sender":false,"user_id":"f6dc589f-bcdb-48d5-8848-d8d60e4cba5b","channel_unread_message_count":2,"state":"joined","push_enabled":true,"push_trigger_option":true,"profile_url":"https:\/\/storyplace-stage.s3.amazonaws.com\/asset\/image\/3047f6c2-2d6e-4ab3-a049-07aef47cd170\/pixel_john.jpg","metadata":{"full_name":"John Baker"}}],"type":"MESG","payload":{"custom_type":"","created_at":1600384455890,"translations":{},"message":"j\n","data":"","message_id":7474767628},"channel":{"is_distinct":true,"name":"a6571743-7093-4d44-8ef9-32130833a930","custom_type":"private","is_ephemeral":false,"channel_url":"eb823739-c8c8-42ee-a078-5b53e47283b7","is_public":false,"is_super":false,"data":"","is_discoverable":false},"sdk":"JavaScript"}'
const signature = 'd73661b11de84a3e3f9ddea30c0bbb678910d4fecaff92d24713456c13178929';
const hash = crypto
    .createHmac('sha256', SENDBIRD_API_TOKEN)
    .update(body)
    .digest('hex');

console.log(hash, signature)
console.assert(hash == signature)

Output

33ccb7af456501fcba477bff967b3752729b979892b6d57d8cf51f3af344cc6d d73661b11de84a3e3f9ddea30c0bbb678910d4fecaff92d24713456c13178929
Assertion failed

Would it be helpful to reach out via email and share our account information so someone from SendBird can test/investigate?

@jhnbkr

For node.js there is one small tweak needed. In the code below I JSON stringify the body as a JSON object and strip out the escaping characters.

JSON.stringify(body).replace(/\//g, '\\/');

const crypto = require('crypto');

const SENDBIRD_API_TOKEN = 'REDACTED';
const body = {"category":"group_channel:message_send","sender":{"nickname":"john","user_id":"f6dc589f-bcdb-48d5-8848-d8d60e4cba5b","profile_url":"https:\/\/storyplace-stage.s3.amazonaws.com\/asset\/image\/3047f6c2-2d6e-4ab3-a049-07aef47cd170\/pixel_john.jpg","metadata":{"full_name":"John Baker"}},"silent":false,"custom_type":"","mention_type":"users","mentioned_users":[],"app_id":"9BDF228D-8E32-43DE-88B7-969BA4CF4C40","sender_ip_addr":"99.231.147.50","members":[{"is_blocking_sender":false,"unread_message_count":11,"total_unread_message_count":11,"is_active":true,"is_online":false,"is_hidden":0,"channel_mention_count":0,"nickname":"youngastro","is_blocked_by_sender":false,"user_id":"6349077a-833a-4eda-bffd-8fc1381ae6f9","channel_unread_message_count":6,"state":"joined","push_enabled":true,"push_trigger_option":true,"profile_url":"https:\/\/storyplace-stage.s3.amazonaws.com\/asset\/image\/b88eff9a-7d01-43ef-8397-d8384c0eab5c\/earth.gif","metadata":{"full_name":"Young Astronaut"}},{"is_blocking_sender":false,"unread_message_count":7,"total_unread_message_count":7,"is_active":true,"is_online":true,"is_hidden":0,"channel_mention_count":0,"nickname":"john","is_blocked_by_sender":false,"user_id":"f6dc589f-bcdb-48d5-8848-d8d60e4cba5b","channel_unread_message_count":2,"state":"joined","push_enabled":true,"push_trigger_option":true,"profile_url":"https:\/\/storyplace-stage.s3.amazonaws.com\/asset\/image\/3047f6c2-2d6e-4ab3-a049-07aef47cd170\/pixel_john.jpg","metadata":{"full_name":"John Baker"}}],"type":"MESG","payload":{"custom_type":"","created_at":1600384455890,"translations":{},"message":"j\n","data":"","message_id":7474767628},"channel":{"is_distinct":true,"name":"a6571743-7093-4d44-8ef9-32130833a930","custom_type":"private","is_ephemeral":false,"channel_url":"eb823739-c8c8-42ee-a078-5b53e47283b7","is_public":false,"is_super":false,"data":"","is_discoverable":false},"sdk":"JavaScript"}
//Body is stringifyed and forward slashes are unescaped
body = JSON.stringify(body).replace(/\//g, '\\/'); 
const signature = 'd73661b11de84a3e3f9ddea30c0bbb678910d4fecaff92d24713456c13178929';

const hash = crypto
    .createHmac('sha256', SENDBIRD_API_TOKEN)
    .update(body)
    .digest('hex');

console.log(hash, signature)
console.assert(hash == signature)
1 Like

@jhnbkr

The problem appears to be because of escape characters in the message (specifically, the message field of the web-hook is the message g\n so you need to hash the escaped version g\\n not the literal g\n ).

In your Python script please try payload = r'{"category"... instead of payload = {"category...

Thanks for this @Jason - Solved the issue for me. Could the documents be updated:

Wasted a bit of time trying to get this to work before I found your post. Both, that you must use the master and that the body must be tidied up before stringifying.

Thanks again for your solution :slight_smile:

Hi! Please update your docs to include this: Webhooks | Chat Platform API | Sendbird Docs. I wasted so many hours on this which would have been saved if this was documented there.

Also, replace(///g, ‘\/’). You’re actually escaping forward slashes, not stripping out the escape characters, right?

'https://www.google.com'.replace(/\//g, '\\/')
> "https:\/\/www.google.com"

Hi Sameer,

If you take in the request as text instead of converting it to JSON, there are no extra steps needed to validate the signature.

They key here is to use

app.use(express.text({ type: 'json' }));

After that, it works as is with:

const body = req.body;
  const signature = req.header('x-sendbird-signature');
  const hash = crypto.createHmac('sha256', token).update(body).digest('hex');

  if (signature == hash) {
    req.body = JSON.parse(req.body);
    next();
  } else {
    res.sendStatus(401);
  }

This is follows how our docs outline utilizing signature validation for Javascript.

I’m using Firebase which just hands me req.body. Maybe there’s a way to configure it to request as text but I don’t see it - Call functions via HTTP requests  |  Firebase.

I see. Thank you for that clarification. I think that adds a bit to the confusion because you have a lot less control from Firebase’s side. I’ll talk with our Engineering team to see if we can formulate a way to rework our documentation to try and clear up some of the information there.

1 Like

@Sameer_Madan, I do see Firebase does allow you to implement your own express application. I’m not saying that is the solution in this case but something to think about.
https://firebase.google.com/docs/functions/http-events#using_existing_express_apps

1 Like