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 is
X-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:
- Parse the signature header to extract the
timestamp
andbase64 encoded signature
- Join
timestamp
with theraw http request body data
with a dot (.
), and generate aSHA256
digest; - Perform a strict base64 decoding on the
based64 encoded signature
to get thedecoded signature
; - Verify the signature using the per-endpoint
public key
,digest
(from Step 2), anddecoded 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
}
Updated 6 months ago