Webhook security

Secure the events that Livestorm sends to your URL.

Introduction

Since the webhook mechanism allows Livestorm to send data to your servers through a public endpoint, the webhooks must provide a way to be authenticated by your application in order to prevent hackers from pushing data into your app.

🚧

The signature only concerns the API managed webhook

Please note that the old webhooks managed in the Livestorm native apps cannot be signed. Livestorm sign only our most recent webhooks managed with our REST APIs.

The authentication strategy must protect the webhook from different types of vulnerabilities:

VulnerabilityDescription
Payload ExposureThe content of the webhook is visible by anyone belonging to the same network.
Replay AttacksAn attacker captures a request, and redoes it in way to gain unauthorized access, or to produce unauthorized effect.
Payload CorruptionThe data in the payload can be altered by an attacker in way to create/modify/delete data.
Unknown webhook sourceThe endopint can accept the webhook notification from anybody, because of the lack of authentication.

The best way to protect the webhooks to the payload exposure is providing a encrypted endpoint (https). Therefore, Livestorm will not accept non encrypted endpoint.

For the rest of the vulnerabilities, Livestorm can provide a signature in the payload so you can verify that the webhook is from us, and the content is not altered before continuing the execution. By a simpoe header, we give you a way to ensure that the payload is not corrupted and coming from our servers.

Webhook signature

Headers

Livestorm can sign all webhook's events sent to your URL with a signature. The signature shows up in the custom header x-livestorm-signature. It contains a timestamp and the signature itself separated by a comma.

x-livestorm-signature: 1688725648,2481018708a4e77a10ee7d7b48dd9f99222035b014fd5055a8455cb476e7c39e

Signature composition

The signature is an hash of the concatenation of these 3 elements :

ElementDescription
Timestamp (in second)It gives you the guarantee that the signature is signed at the right timestamp. If the timestamp before the coma is changed, the signature will mismatch and you should drop the request. Moreover, if the timestamp is still valid, but old (more than X seconds) you should also drop the request. It will prevent from getting replay attacks
Secret keyThe secret is provided by Livestorm. It will never be displayed in the payload. It is like a salt in the hash to ensure that the webhook is signed by Livestorm.
PayloadPutting the payload in the hash gives you the guarantee that it has not be modified after the signature generation.

The order of the concatenation is: timestamp + secret key + payload

These 3 elements are hashed based on the SHA256 cryptographic hash.

Verifying the signature

To verify the signature, you should concatenate the three elements and then hashing the string with the SHA256 function. Then, you should compare the hash to the signature. If the timestamp looks fresh enough and the signature is verified, you can accept the request.

Example

## Split the x-livestorm-signature on the "," (comma) character.
payload_timestamp = '' # first part of the livestorm header
payload_signature = '' # second part of the livestorm header, the hash

acceptable_age = 5 # the tolerance to the age of the request

hash = Digest::SHA256.new
hash.update(timestamp)
hash.update(secret_key)
hash.update(payload)
my_signature = hash.hexdigest

# or, in a shorter way
my_signature = Digest::SHA256.hexdigest("#{timestamp}#{secret_key}#{payload}")

## then compare your signature to the one in the headers

if my_signature != payload_signature || Time.now().to_i - payload_timestamp > acceptable_age 
  # stop the process
end

<?php
// Split the x-livestorm-signature on the "," (comma) character.
$payloadSignature = ''; // first part of the livestorm header
$payloadTimestamp = ''; // second part of the livestorm header, the hash

$acceptableAge = 5; // the tolerance to the age of the request
  
$mySignature = hash('sha256', $timestamp . $secret . $payload);

if ($payloadSignature != $mySignature || time() - $payloadTimestamp > $acceptableAge) {
  //stop the process
} 

const { createHash } = require('crypto');

// Split the x-livestorm-signature on the "," (comma) character.
const payloadSignature = ''; // first part of the livestorm header
const payloadTimestamp = ''; // second part of the livestorm header, the hash

const acceptableAge = 5; //the tolerance to the age of the request

const mySignature = createHash('sha256').update(timestamp + secret + payload).digest('hex');

if (payloadSignature != mySignature || (Date.now() / 1000) - payloadTimestamp > acceptableAge) {
  //stop the process
} 

🚧

Common mistake

Don't apply any additional formatting to any of the previous element; take it as it is. If you apply formatting, it will add white-space characters that will result in a wrong signature construction.

Activate the signature

By default, the webhook signature is not activated. To proceed to the activation, please contact your CSM or our support team.

Once the signature mechanism is activated for your Livestorm workspace, the webhooks HTTP POST requests will include the header x-livestorm-signature.

Please note that the old webhooks managed in the Livestorm native apps cannot be signed. Livestorm sign only our most recent webhooks managed with our REST APIs.

Full example in Ruby

Below is a full example embed in a tiny web-server to illustrate:

#!/usr/bin/env ruby

require "webrick"
require "digest"

# To install the dependency:
# $ gem install webrick

SECRET_KEY = "my_secret_key"

class MyServlet < WEBrick::HTTPServlet::AbstractServlet
    def do_POST (request, response)
        # read timesamp and signature from "x-livestorm-signature" header
        signature_header = request.header["x-livestorm-signature"].first
        timestamp, signature = signature_header.split(",")

        # read payload form the request body
        payload = request.body

        # either sign this way
        hash = Digest::SHA256.new
        hash.update(timestamp)
        hash.update(SECRET_KEY)
        hash.update(payload)
        my_signature1 = hash.hexdigest
        
        # or or this one (shorter ^^)
        my_signature2 = Digest::SHA256.hexdigest("#{timestamp}#{SECRET_KEY}#{payload}")

        puts "---"
        puts "My 1st signature: #{my_signature1}"
        puts "My 2nd signature: #{my_signature2}"
        puts "The expected one: #{signature}"
        puts "---"
        
        response.body = "OK"
        response.status = 200
    end
end

server = WEBrick::HTTPServer.new(:Port => 8888)

server.mount "/", MyServlet

# Ctrl + C to shutdown
trap("INT") {
    server.shutdown
}

server.start