Validating Webhook Requests
We recommend validating the signature on all incoming webhook requests to protect against malicious actors sending requests to your webhook endpoint.
Each webhook request will be signed by Streem's backend, and the signature included as an HTTP header along with the request. The signature uses the HMAC-SHA-256 algorithm with a customer-provided signing key.
Webhook configuration
- Each
Webhook
can be configured by the customer with one or more signing keys (see "Webhook Signing Keys" below). - Each
Webhook.Header
has abool include_in_request_signature
field that indicates whether that header should be included in the signature computation.
Webhook signing keys
TODO: describe how to manage webhook signing keys
Webhook request contents
The webhook HTTP request uses the following HTTP headers:
Streem-Signature-Headers
- Contains the names of the headers used to compute the signature.
- The format of this field is a colon-separated list with no spaces (e.g.
Header1:Header2:Header3
).
Streem-Signature
- Contains the computed signature. If the webhook is configured with multiple signing keys (e.g. during key rotation), then the request will contain a comma-separated list of signatures (one for each key).
Streem-Sent-At
- Contains the same value as in the
sent_at
field of the webhook request (see example below). This is included as a header so that it is possible to validate the timestamp of the signed request without decoding the request body. - The format of this field is an RFC 3339 timestamp in the UTC timezone (e.g.
1972-01-01T10:00:20.021Z
).
- Contains the same value as in the
Validating the signature
Signature details
The webhook request signature is computed over:
- The timestamp of the request, as provided in the
Streem-Sent-At
header - Any custom headers defined for that webhook (sorted alphabetically) that have the
Webhook.Header.include_in_request_signature
field set totrue
- The HTTP request body
- If the request is using
GET
instead ofPOST
, then the signature should be computed using the request body before it is URL-encoded into thebody
query parameter.
- If the request is using
Validation algorithm
After receiving the webhook request message, the recipient should validate the HMAC signature by attempting to re-create the signature by hashing the raw message body with the signing keys.
To validate the request:
- Check that the timestamp in the
Streem-Sent-At
header is within a reasonable period of time (e.g. not more than 5 minutes in the past or 5 minutes in the future). - Ensure that the
Streem-Signature-Headers
header contains the custom headers expected by the recipient. - Re-create the signature:
- Extract the name and value of each header listed in
Streem-Signature-Headers
from the HTTP request. Make sure to preserve the order of headers as listed inStreem-Signature-Headers
, as well as the capitalization of the header names, when computing the signature below. - Extract the text of the HTTP request body as an array of bytes.
- If the request was made using
POST
: the entire body of thePOST
request is used, including line endings. - If the request was made using
GET
: the value of thebody
query parameter is used. The value should be URL-decoded and encoded to a byte array using the UTF-8 encoding.
- If the request was made using
- Combine the headers and body into a single array of bytes. Each header name and value should be separated with an
=
character, and the headers and body should be separated from each other using a;
character. For example:Header1=value1;Header2=value2;body text
. - Compute a SHA-256 HMAC digest for the array of bytes using the signing key, and encode the digest using the
base64url
encoding (see https://www.rfc-editor.org/rfc/rfc4648#section-5).
- Extract the name and value of each header listed in
- Compare the
base64url
digest from the previous step to the values of theStreem-Signature
header. There will be one value in theStreem-Signature
header for each signing key configured for your account. At least one of the header values must exactly match the computed digest. If there is no match, then the webhook request may be compromised and it should not be trusted. Only one match is required.
The code example below demonstrates the process of implementing this message authenticity check.
Python pseudo-code for validating the signature
def validate_webhook_request(
# HTTP request object
req,
# the shared signing key
signing_secret: str,
):
sent_at = req.headers['Streem-Sent-At'][0]
# make sure the request is current
req_time_skew = datetime.now() - datetime.fromisoformat(sent_at)
allowed_skew = timedelta(seconds=5) # adjust timedelta as desired
if req_time_skew > allowed_skew:
raise RuntimeError('Request timestamp is too far in the past')
elif req_time_skew < -allowed_skew:
raise RuntimeError('Request timestamp is in the future')
# make sure the custom headers that were defined for the webhook
# are covered by the signature
header_names = req.headers['Streem-Signature-Headers'][0].split(':')
if 'My-Custom-Header' not in header_names:
raise RuntimeError("Request signature does not cover the 'My-Custom-Header' header")
# calculate the signature input
input_parts = []
for header_name in header_names:
input_parts.append(f"{header_name}={req.headers[header_name][0]}")
input_msg = ';'.join(input_parts).encode('utf-8') + b';' + req.body
# calculate the signature
expected_signature = hmac.new(
digest='sha256',
key=signing_secret.encode('utf-8'),
msg=input_msg,
).hexdigest()
# validate that one of the signatures in the request matches the expected signature
signatures = [sig.strip() for sig in req.headers['Streem-Signature'][0].split(',')]
if not any(hmac.compare_digest(expected_signature, s) for s in signature):
raise RuntimeError('Request signature is invalid')
Example
Given a webhook configured with:
- Signing key shared secret:
s3kr3t
- Custom headers:
ExampleCom-ClientId: abcde12345
When the WebhookRequestBody
is:
{
"request_sid": "wh_4WUzV0k2jOrSWwytOjT13e:wev_5kjwaB2gr8dHU9coezxMqR:1",
"webhook_sid": "wh_4WUzV0k2jOrSWwytOjT13e",
"company_sid": "co_2syD0fVA3Cr6H9IKIrYNID",
"sent_at": "2022-11-25T17:50:32.114703Z",
"event": {
"event_sid": "wev_5kjwaB2gr8dHU9coezxMqR",
"event_type": "group_reservation_created",
"occurred_at": "2022-11-25T17:50:31.849273Z",
"payload": {
"@type": "type.streem.com/streem.api.events.GroupReservationCreated",
"group_reservation": {
"company_sid": "co_2syD0fVA3Cr6H9IKIrYNID",
"reservation_sid": "rsv_98FNaZqZUf7ul6u00oUUWA",
"external_user_id": "96cabbba-ee34-4460-aabb-b49c1ef8106b",
"reservation_status": "GROUP_RESERVATION_STATUS_QUEUED",
"queue_position": 7,
"group": {
"sid": "grp_26Wat5GXaPkmDd8eK6NgZQ",
"name": "AGENT"
}
}
}
}
}
Then the HTTP request to the webhook will be:
TODO: update the Streem-Signature
value to be correct for the example request headers
POST /some/webhook/url HTTP/1.1
Host: some.webhook.host
Streem-Signature-Headers: Streem-Sent-At:ExampleCom-ClientId
Streem-Signature: 6fbe2aee586b0d5d0d0fcf2e54e82e2e49f45e5b7a3aefdb5497a914082f3714
Streem-Sent-At: 2022-11-25T17:50:32.114703Z
ExampleCom-ClientId: abcde12345
Content-Type: application/json
{
"request_sid": "wh_4WUzV0k2jOrSWwytOjT13e:wev_5kjwaB2gr8dHU9coezxMqR:1",
"webhook_sid": "wh_4WUzV0k2jOrSWwytOjT13e",
"company_sid": "co_2syD0fVA3Cr6H9IKIrYNID",
"sent_at": "2022-11-25T17:50:32.114703Z",
"event": {
"event_sid": "wev_5kjwaB2gr8dHU9coezxMqR",
"event_type": "group_reservation_created",
"occurred_at": "2022-11-25T17:50:31.849273Z",
"payload": {
"@type": "type.streem.com/streem.api.events.GroupReservationCreated",
"group_reservation": {
"company_sid": "co_2syD0fVA3Cr6H9IKIrYNID",
"reservation_sid": "rsv_98FNaZqZUf7ul6u00oUUWA",
"external_user_id": "96cabbba-ee34-4460-aabb-b49c1ef8106b",
"reservation_status": "GROUP_RESERVATION_STATUS_QUEUED",
"queue_position": 7,
"group": {
"sid": "grp_26Wat5GXaPkmDd8eK6NgZQ",
"name": "AGENT"
}
}
}
}
}
Updated over 1 year ago