Configuring and Verifying Webhook Signatures

Use Webhook Signatures to verify that webhooks are coming from OpsLevel.

OpsLevel webhooks are sent with a signature that the destination server can use to verify that the event came from the OpsLevel platform and not a third party or malicious system.

Setting up your Account's Signing Secret

Note: You must have an Admin role to configure your account's Signing Secret

  1. Navigate to the Account page
  2. Scroll to the Webhook Signing section
  3. Press + Create Signing Secret
  4. When you are ready to securely store the Signing Secret, press the Create Signing Secret button in the modal dialog that appears. Note: After the dialog is closed, you will not be able to retrieve the Signing Secret, so ensure that you have a way to securely store the secret.
  5. Copy the Signing Secret before closing the dialog.

Rotating the Account's Signing Secret

Once a Signing Secret has been created, you may rotate the secret by first deleting the existing Signing Secret.

  1. Navigate to the Account page
  2. Scroll to the Webhook Signing section
  3. Press Delete Signing Secret
  4. Press Yes to confirm the action and Delete the Secret
  5. Follow the steps to Create a new Signing Secret

Note: Removing the original Signing Secret will break any existing signature verification that you may have in place. Be sure to be prepared to update the systems that validate the Signing Secret or temporarily disable them during the rotation to reduce or eliminate any outages.

How to verify signatures

Once a Signing Secret is configured for an OpsLevel Account, all OpsLevel webhooks sent from that Account will include an additional signature header: X-OpsLevel-Signature.

A pseudo-algorithm to verify the signature of a webhook delivery is described below.

Step 1: Extract the signature from the request

  • Extract the signature string from the X-OpsLevel-Signature header on the request. (This will look like sha256=<SOME_SIGNATURE>)

Step 2: Compute the expected valid signature

Using the received JSON payload, calculate the signature by:

  • Start with the entire request, then remove any headers that were not part of the original request other than the X-OpsLevel-Timing header.
    For Actions, this should only include explicit headers that are part of the action configuration and the X-OpsLevel-Timing header.
    In all cases, ensure that the X-OpsLevel-Signature header has been removed before proceeding.
  • Sort the remaining headers alphabetically
  • Compute the SHA-256 HMAC on the clean and sorted request using the shared secret as the key.
  • Take the Hex encoding of the result to obtain a value we can compare.

Step 3: Compare the signatures

Compare the expected signature obtained in Step 2 with the provided signature from Step 1.

If the signatures match, the webhook should be considered a trusted and authentic request from OpsLevel that can be processed by the server.

🚧

Use the original request!

Computing the signature is sensitive to any change in the headers and request body. Code or middleware that changes the request before the signature is computed will result in the signatures not matching. Common causes of mismatched signatures include: capitalization changes, whitespace changes, character encoding changes, and line-ending changes (i.e. from \n to \r\n or vice versa).

Sample Code Written in Python

import hmac
import hashlib

class OpsLevelVerifier:
    def __init__(self, key):
        self.key = key
        
    def verify(self, request, signature):
        comparisons = []
        byte_key = self.key.encode("ASCII")
        signature = "sha256=" + hmac.new(byte_key, request.encode(), hashlib.sha256).hexdigest()
        return hmac.compare_digest(signature, _signature)
        
        
// Clean request headers
def headers_to_keep = { "X-OpsLevel-Timing" };
def signature = request.headers["X-OpsLevel-Signature"];
delete request.headers["X-OpsLevel-Signature"];

request.headers = {key: request.headers[key] for key in request.headers.keys()
       & headers_to_keep}

opsLevelVerifier = OpsLevelVerifier(key)
opsLevelVerifier.verify(request, signature)

Sample Code Written in JavaScript

const crypto = require('crypto');
module.exports = class OpsLevelVerifier {
  constructor(key) {
    this.key = key;
  }

  verify(request, signature) {
    var computedSignature = crypto
      .createHmac('sha256', key)
      .update(request)
      .digest('hex');

    if (("sha256=" + computedSignature).indexOf(signature) > -1 ) {
      return true;
    } else {
      return false;
    }
  }
}

const OpsLevelVerifier = require('./opslevel_verifier.js');
const verifier = new OpsLevelVerifier(key);

// Clean request headers
const headers_to_keep = [ 'X-OpsLevel-Timing' ];
const signature = request.headers[ 'X-OpsLevel-Signature' ];
delete request.headers[ 'X-OpsLevel-Signature' ];

for( var key : request.headers ) {
	if(!headers_to_keep.includes(key)) {
    delete request.headers[key];
  }
}

verifier.verify(request, signature);

Sample Code Written in Ruby

def verify_webhook(headers, request_body)
	# Remove and store the signature we are verifying
  hmac_header = headers.delete("X-OpsLevel-Signature")
  
	# Only keep a subset of headers for signature verification
  headers_to_keep = ['X-OpsLevel-Timing']
  header_string = headers.keep_if{ |key| headers_to_keep.include? key }.map { |key, value| "#{key}:#{value}" }.sort.join(",")
  payload = "#{header_string}+#{request_body}"

  calculated_hmac = OpenSSL::HMAC.hexdigest('sha256', CLIENT_SECRET, payload)
  ActiveSupport::SecurityUtils.secure_compare("sha256=#{calculated_hmac}", hmac_header)
end