Webhook Event Signature Verification
Bridge signs all webhook event deliveries to help you verify their authenticity and protect against replay attacks.
Signature Header Format
Each webhook request includes a signature in the X-Webhook-Signature header:
X-Webhook-Signature: t=<timestamp>,v0=<base64-encoded-signature>
t
is a timestamp in milliseconds.v0
is the base64-encoded signature
Verification Steps
To verify the signature:
- Extract the
timestamp
andsignature
from theX-Webhook-Signature
header. - Concatenate the timestamp and raw HTTP request body using a dot (.) as the delimiter:
data = "<timestamp>.<raw-body>"
- Generate a SHA256 digest of the data.
- Base64-decode the signature (
v0=<signature>
). - Verify the decoded signature using:
- The
public key
assigned to your webhook - The digest
- The
decoded signature
(from previous step)
- The
Replay Protection
Bridge recommends rejecting events older than 10 minutes based on the timestamp and return a 400 status to request retries. For every retry, Bridge generates a fresh signature with a new timestamp.
Test Data
You can use the following test data to validate your implementation:
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
Reference implementations are provided below in multiple languages. These examples demonstrate how to:
- Parse the signature
- Validate timestamps
- Verify the SHA256 signature using the provided PEM-encoded public key
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 10 days ago