Webhook Event Signature Verification

For each event delivery, Bridge will include a webhook signature header in the HTTP request for event authenticity verification

  • The header name isX-Webhook-Signature ;
  • The header value format is t=<timestamp>,v0=<base64 encoded signature> , where the timestamp is in milliseconds;

To verify the signature, follow these steps:

  1. Parse the signature header to extract the timestamp and base64 encoded signature
  2. Join timestamp with the raw http request body data with a dot (.), and generate a SHA256 digest;
  3. Perform a strict base64 decoding on the based64 encoded signature to get the decoded signature;
  4. Verify the signature using the per-endpoint public key, digest (from Step 2), and decoded signature (from Step 3

🚧

To avoid Replay Attacks, Bridge advises the receiving endpoint to disregard events that are older than a few minutes, e.g. 10 minutes, and return a 400 status to request retries. For each event delivery retry, Bridge generates a new timestamp.

Sample Code

Our signature generation is performed with two passes of hashing. Please review the sample code below carefully to ensure your verification code aligns with it and passes the signature example provided below.

Test data

public_key = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtqsEE4eI7EmzhcquGJXt\nLX9PMK0UH6Kl1WIR21sv8HtueG8BuvvpP3MiN7ltzmIhS8KaynCjN4l+620PnXeu\nxWG+CSnEdkinL9hCqbEid5vv9zl0j9LWiJx3FkKHqADU7cgm46aa8dKUdIQYF2X+\nO7WmyLkC4wUM/mWhBPMsIQBznashRMZxx7XJjsVp27ACUE4eNIjEXbVYN6U8jSbU\nhG++CfL8xXu+GHDqKmFE6Po6HnuURvLFVnCtE3mXXBcVFlPy+octfx8nOMLT3X8O\n9UehIigJ34o2yMm/Fq3HUJzg2BsiAiGgtr0vmeoV9Q7upSNj9TuOumAzZFi4pYA+\nqwIDAQAB\n-----END PUBLIC KEY-----\n"

signature_header = "t=1705854411204,v0=jz/0dmHJ63FAzacGutrDTEoq+iSz/PHm/ugdooXDQu5NwuVIT2LmZGjsnCsBHgR9Py6OBP9zurzW4dHgygU4EDqmMPTUOvhvndYb4lWt+TY66LihaFI2whL6DAf/jb1QjYjNU0A6x9SLzC45dgE6X7zTDUM+2Z+scG/WEQf6SxQMt4E2sEipl5PqMK5lYUe3otdJV+X2c9D64bGwCEE7QSia+Vhozg8QNOQEk/rdz2IEONIg6oC43CeiN4E2kF9XLAGuy9uAHx9O9OJH5ZPLJZjyo4VcXYeWQgxaQ1gZ1Qu6hEEzgiPSff/1nou58dm4bIIazgCWli/mO0NyGcpfFw=="

body_data = "{\"message\":\"Hello World!\"}"

Sample Implementations

You may reference the following example implementations of signature verification when writing your code. Please contact us if you find any issues with the provided code or if your preferred language is not provided below.

class WebhookController < ApplicationController
  extend T::Sig

  sig { void }
  def process_event
    signature_header = request.headers["X-Webhook-Signature"]
    _, timestamp, signature = signature_header.match(/^t=(\d+),v0=(.*)$/).to_a

    unless timestamp.present? && signature.present?
      render_400("Malformed signature header") 
      return
    end

    if Time.at(timestamp / 1_000) < 10.minutes.ago
      render_400("Invalid signature!") 
      return
    end

    body_data = request.body.read

    data = "#{timestamp}.#{body_data}"
    decoded_signature = Base64.strict_decode64(signature)

    digester = OpenSSL::Digest.new('SHA256')
    digest = digester.digest(data)

    verified = public_key.verify(digester, decoded_signature, digest)
    unless verified
      render_400("Invalid signature!")
      return
    end

    body_json = JSON.parse(body_data)

    # Store the event for asynchronous processing and return `200` status as
    # quickly as possible.

    render json: {
      message: "Event processing OK!",
    }, status: 200    
  end

  sig { params(message: String).void }
  def render_400(message)
    render json: { message: }, status: 400
  end

  sig { returns(OpenSSL::PKey::RSA) }
  def public_key
    # Assume the public key is initiazed in the app context by invoking an OpenSSL
    # method with the public key PEM issued by Bridge:
    #
    #   OpenSSL::PKey::RSA.new(public_key_pem)

    # Replace the line below with the initialized RSA public key from app context.
    <initialized RSA public key>
  end
end
// ChatGPT assisted translation from the Ruby version.

import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@WebServlet("/webhook")
public class WebhookServletWithPublicKey extends HttpServlet {

    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String signatureHeader = request.getHeader("X-Webhook-Signature");
        if (signatureHeader == null) {
            renderRequestInvalid(response, "Signature header missing")
						return
        }

        Pattern pattern = Pattern.compile("^t=(\\d+),v0=(.*)$");
        Matcher matcher = pattern.matcher(signatureHeader);
        if (!matcher.find()) {
            renderRequestInvalid(response, "Invalid signature header")
						return
        }

        String timestampMillis = matcher.group(1);
        String signature = matcher.group(2);

        if (timestampMillis == null || signature == null) || !isTimestampWithinLastMinutes(timestamp, 10) {
            renderRequestInvalid(response, "Invalid signature")
            return
        }

        String bodyData = getBody(request);
        String data = timestampMillis + "." + bodyData;

        try {
            PublicKey publicKey = loadPublicKey(); // Implement this method to load your public key

            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] dataDigest = digest.digest(data.getBytes());

            Signature sig = Signature.getInstance("SHA256withRSA");
            sig.initVerify(publicKey);
            sig.update(dataDigest);

            if (!sig.verify(Base64.getDecoder().decode(signature))) {
                renderRequestInvalid(response, "Invalid signature")
                return
            }

						// Store the event for asynchronous processing and return 200 
            // as quickly as possible.

            response.getWriter().print("Event processing OK!");
        } catch (SignatureException e) {
            renderRequestInvalid(response, "Invalid signature")
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            throw new ServletException("Error verifying signature", e);
        }
    }

    private String getBody(HttpServletRequest request) throws IOException {
        StringBuilder sb = new StringBuilder();
        BufferedReader reader = request.getReader();
        String line;
        while ((line = reader.readLine()) != null) {
            sb.append(line).append('\n');
        }
        return sb.toString().trim();
    }

    private PublicKey loadPublicKey() throws GeneralSecurityException {
        String publicKeyPEM = "-----BEGIN PUBLIC KEY-----\n" +
                "YourPublicKeyHere\n" +
                "-----END PUBLIC KEY-----";
        
        publicKeyPEM = publicKeyPEM.replace("-----BEGIN PUBLIC KEY-----\n", "")
                                   .replace("-----END PUBLIC KEY-----", "")
                                   .replaceAll("\\s", "");
        
        byte[] encoded = Base64.getDecoder().decode(publicKeyPEM);
        
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        X509EncodedKeySpec keySpec = new X509EncodedKeySpec(encoded);
        return keyFactory.generatePublic(keySpec);
    }

    private static boolean isTimestampWithinLastMinutes(long timestampMillis, int minutes) {
        long nowMillis = Instant.now().toEpochMilli();
        long differenceMillis = nowMillis - timestampMillis;
        return differenceMillis <= minutes * 60 * 1000;
    }
}
# ChatGPT assisted translation from the Ruby version.

from flask import Flask, request, jsonify
import base64
import hashlib
import time
from datetime import datetime, timedelta
import json
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.serialization import load_pem_public_key

app = Flask(__name__)

# Assume the public key is loaded here as an environment variable or a file
PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
...your public key PEM here...
-----END PUBLIC KEY-----"""
public_key = load_pem_public_key(PUBLIC_KEY_PEM.encode())

def verify_signature(timestamp, body_data, signature):
    data = f"{timestamp}.{body_data}"
    digester = hashlib.sha256(data.encode())

    decoded_signature = base64.b64decode(signature)

    try:
        public_key.verify(decoded_signature, digester.digest(), padding.PKCS1v15(), hashes.SHA256())
        return True
    except InvalidSignature:
        return False

@app.route("/webhook", methods=["POST"])
def process_event():
    signature_header = request.headers.get("X-Webhook-Signature")
    if not signature_header:
        return jsonify(message="Malformed signature header"), 400

    match = re.match(r"^t=(\d+),v0=(.*)$", signature_header)
    if not match:
        return jsonify(message="Malformed signature header"), 400

    timestamp, signature = match.groups()

    if not timestamp or not signature:
        return jsonify(message="Malformed signature header"), 400

    if datetime.fromtimestamp(int(timestamp) / 1_000) < datetime.now() - timedelta(minutes=10):
        return jsonify(message="Invalid signature!"), 400

    body_data = request.data.decode() # NB: Make sure it's the raw body data.

    if not verify_signature(timestamp, body_data, signature):
        return jsonify(message="Invalid signature!"), 400

    body_json = json.loads(body_data)

    # Store the event for asynchronous processing and return `200` status as
    # quickly as possible.
    
    return jsonify(message="Event processing OK!"), 200

if __name__ == "__main__":
    app.run(debug=True)
// ChatGPT assisted translation from the Ruby version.

const express = require('express');
const bodyParser = require('body-parser');
const crypto = require('crypto');

const app = express();
const port = 3000;

app.use(bodyParser.raw({ type: 'application/json' }));

const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
...your public key PEM here...
-----END PUBLIC KEY-----`;

function verifySignature(timestamp, body, signature) {
    const hash = crypto.createHash('SHA256');
    hash.update(timestamp + "." + body)

    const verifier = crypto.createVerify('SHA256');
    verifier.update(hash.digest());
    verifier.end();

    return verifier.verify(PUBLIC_KEY, Buffer.from(signature, 'base64'));
}

app.post('/webhook', (req, res) => {
    const signatureHeader = req.headers['x-webhook-signature'];
    if (!signatureHeader) {
        return res.status(400).json({ message: 'Malformed signature header' });
    }

    const [, timestamp, signature] = signatureHeader.match(/^t=(\d+),v0=(.*)$/) || [];
    if (!timestamp || !signature) {
        return res.status(400).json({ message: 'Malformed signature header' });
    }

    if (new Date(parseInt(timestamp, 10)) < new Date(Date.now() - 10 * 60 * 1000)) {
        return res.status(400).json({ message: 'Invalid signature!' });
    }

    // NB: Ensure the raw body data from the request is utilized. If the framework
    //   does not support this functionality out of the box, you may need to find
    //   a method to capture the raw body data.
    if (!verifySignature(timestamp, req.rawBodyData, signature)) {
        return res.status(400).json({ message: 'Invalid signature!' });
    }

    // Parse the JSON body
    const bodyJson = JSON.parse(req.body.toString());

    // Store the event for asynchronous processing and return `200` status as
    // quickly as possible.

    res.status(200).json({ message: 'Event processing OK!' });
});

app.listen(port, () => {
    console.log(`Webhook listener running on port ${port}`);
});
package main

import (
	"crypto"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/base64"
	"encoding/pem"
	"fmt"
)

const (
	publicKeyPem = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu/uzhd9v0g2+0g8AyoVu\nBg/mpVIXULDuAKQIpc9rFrfl0XdZ/uNZmeBtkuejOmEmjKRK224RRO3iH+xRy7X2\n3cEaJHqcE+q0bBGTYh1OcbiySgE02H6ptL2tUo/HihSwn2LBkJ8lFUXatPUqKjXA\nDyXsQAC204LDZSo8w1j32gDQM0jCM+Zh9Hhoo7sKVAU8Pei8XrvLiQywb+EMzGQf\n7r1DGc3c4oFkRRnfQiMMoAmq68BC3yhQchfe7Q9Sn931DsVKjkMJ1Oy+/t2mxTBX\nt4la4mQy4AZd0obsIt1KXMix7FGuAoWgt9xkxkBW7D8WTbW9u100YgobwGqE82ja\nIQIDAQAB\n-----END PUBLIC KEY-----\n"

	timestamp = "1705854411204"

	signature = "VCgBICzORlcmi80KoWZDrzRIbVtdwKrk4vOXea4Zdj9PS4U9HDNghGnxAhhtXcT7Hx7eErrPSX3iPA33pSnbvPjsNL522FrfkqiNGB5e6EebLYJo7++TBAV+jcUL0d7rFONhxE63pDIMzKD1RksdqwGnw0jnVClIyiLRru9URtnkVVVCZZmGrHlX40cusL2LAmVKVHl7ugsp86fVIWgn4vTyWUux1C/PBUyJELKd4qDWpKO7zkM0Zt6ei8sAuTQBZmmCjOZu39gQUFIgDexYnETt/kiqOJxilulGmTkJA+ni4xYYWwnExjdW7YV4D1In1Iu2p4Zos1iltNahEFbmNw=="

	bodyData = "Hello World!"
)

func main() {
	verified, err := VerifySignature(timestamp, bodyData, signature, publicKeyPem)
	fmt.Println("Verified: ", verified, err)
}

func VerifySignature(timestamp, body, signature string, publicKey string) (bool, error) {
	block, _ := pem.Decode([]byte(publicKey))
	if block == nil {
		return false, fmt.Errorf("public key decoding failed")
	}

	pub, err := x509.ParsePKIXPublicKey(block.Bytes)
	if err != nil {
		return false, err
	}


	sigBytes, err := base64.StdEncoding.Strict().DecodeString(signature)
	if err != nil {
		return false, err
	}

	msgHash := sha256.Sum256([]byte(timestamp + "." + body))

	msgHash = sha256.Sum256(msgHash[:]) // NB: one extra hashing is required.

	err = rsa.VerifyPKCS1v15(pub.(*rsa.PublicKey), crypto.SHA256, msgHash[:], sigBytes)
	if err != nil {
		return false, err
	}

	return true, nil
}