Callback Signing
Callback signing is a method to secure your webhook endpoints from foreign requests by verifying that the incoming requests are genuine and originate from a trusted source.
Overview
When you receive a webhook notification, it is crucial to validate that the request has not been tampered with. This is achieved by computing a SHA-256 HMAC of the entire HTTP body content using a shared callback signing key. This computed HMAC should match the value sent in the Idenfy-Signature
header.
The signing key is set for each webhook notification individually by navigating to Settings -> Notifications. You'll find the Signing key input field under the Headers section when opening/editing the notification itself.
Important Considerations for Middleware Setup
When setting up your webhook listener, it is essential to use the correct middleware to ensure that the raw body content of the request is preserved. This is necessary for accurate HMAC computation.
Use express.raw()
for Raw Body Handling in Node.js
In Node.js applications, using the wrong middleware can cause validation to fail due to modified request bodies. Ensure you use express.raw()
instead of express.json()
or express.urlencoded()
.
-
Incorrect Middleware:
app.use(express.json());
app.use(express.urlencoded({ extended: true }));Why it's Incorrect:
Theexpress.json()
andexpress.urlencoded()
middlewares parse the incoming request body and convert it into a JSON object or URL-encoded string. This transformation changes the original body content, causing the HMAC digest computed from it to differ from the value sent in theIdenfy-Signature
header. -
Correct Middleware:
app.use(express.raw({ type: '*/*' }));
Why it's Correct:
Theexpress.raw()
middleware ensures that the request body is passed as a raw buffer without any transformations, preserving its integrity for accurate HMAC computation.
You should not use the standard ==
operator to compare HMAC digests, as it is susceptible to timing attacks. Instead, you should use hmac.compare_digest
, crypto.timingSafeEqual
, or an equivalent constant-time string comparison method to avoid such vulnerabilities.
Additionally, make sure that webhook notifications are not passing through a proxy and are not being modified by a load balancer or firewall in the destination API. Any proxy or intermediary that alters the payload (even slightly) will cause checksum differences, resulting in failed validations.
Common Errors and How to Troubleshoot
If you encounter errors when validating webhook signatures, review the following troubleshooting steps:
-
Signature Mismatch:
If you see an error message similar toRequest body digest did not match Idenfy-Signature header
, it is likely that the request body was altered by middleware or a proxy server. Useexpress.raw()
to prevent this issue. -
Incorrect Signature Length:
If you receive an error likeERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH
, ensure that theIdenfy-Signature
header and computed HMAC digest have the same length before comparing them. This error often occurs when the signature or digest is formatted incorrectly (e.g., hex vs. base64 encoding). -
Missing Headers or Body:
Verify that the request includes theIdenfy-Signature
header and that the body content is not empty. If either of these is missing, return a400 Bad Request
response with an appropriate error message.
Proxy and Middleware Interference
Additionally, make sure that webhook notifications are not passing through a proxy and are not being validated by the destination API. A proxy might modify the payload, which will result in checksum differences and cause the validation to fail.
Example Implementations
Below are example implementations in Python and JavaScript for validating the webhook signature. Ensure that you adapt these examples to use the correct middleware setup as described above.
- Python
- Python full example
- Javascript
- Javascript full example
- C#
import hmac
CALLBACK_SIGNING_KEY = "Agegb..."
@app.route('/idenfy-callback')
def callback():
signature = hmac.new(
CALLBACK_SIGNING_KEY.encode(),
request.get_data(),
"sha256"
).hexdigest()
request_signature = request.headers.get("Idenfy-Signature")
if request_signature:
if hmac.compare_digest(signature, request_signature):
handle_request(request)
import hmac
import http.server
import socketserver
CALLBACK_SIGNATURE_KEY = 'my_signature_key_from_iDenfy' # Replace with your own signature key
class WebhookHandler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
content_length = int(self.headers.get('Content-Length'))
request_data = self.rfile.read(content_length)
request_signature = self.headers.get("Idenfy-Signature")
if not request_signature:
self.send_error(400, "Idenfy-Signature header is missing")
return
signature = hmac.new(
CALLBACK_SIGNATURE_KEY.encode(),
request_data,
"sha256"
).hexdigest()
print(f"Request signature: {request_signature}")
print(f"Calculated signature: {signature}")
if not hmac.compare_digest(signature, request_signature):
self.send_error(400, "Invalid signature")
return
print("Valid signature")
self.send_response(200)
self.end_headers()
self.handle_request(request_data)
def handle_request(self, request_data):
# Handle the webhook request here
print(f"Request data: {request_data}")
if __name__ == "__main__":
port = 8080
httpd = socketserver.TCPServer(("", port), WebhookHandler)
print(f"Webhook server started on port {port}")
httpd.serve_forever()
const CALLBACK_SIGNING_KEY = "Agegb...";
function verifyPostData(req, res, next) {
const payload = req.rawBody;
if (!payload) {
return next('Request body empty.')
}
const hmac = crypto.createHmac('sha256', CALLBACK_SIGNING_KEY)
const digest = Buffer(hmac.update(payload).digest('hex'))
const checksum = Buffer(req.headers['idenfy-signature'])
if (!checksum || !digest || !crypto.timingSafeEqual(checksum, digest)) {
return next(`Request body digest (${digest}) did not match Idenfy-Signature (${checksum}).`)
}
return next()
}
const express = require('express');
const crypto = require('crypto');
const CALLBACK_SIGNATURE_KEY = 'my_signature_key_from_iDenfy'; // Replace with your own signature key
const app = express();
app.use(express.raw({ type: '*/*' }));
app.post('/webhook', (req, res) => {
const payload = req.body;
const signature = req.headers['idenfy-signature'];
if (!payload || payload.length === 0) {
return res.status(400).send('Request body empty.');
}
if (!signature) {
return res.status(400).send('Idenfy-Signature header missing.');
}
const hmac = crypto.createHmac('sha256', CALLBACK_SIGNATURE_KEY);
hmac.update(payload);
const digest = hmac.digest('hex');
console.log('payload: ', payload);
console.log('signature: ', signature);
console.log('digest: ', digest);
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest))) {
return res.status(400).send('Request body digest did not match Idenfy-Signature header.');
}
console.log('Request signature verified.');
res.status(200).send('Webhook verified.');
});
app.listen(3000, () => {
console.log('Server started on port 3000.');
});
using System;
using System.Net;
using System.IO;
using System.Text;
using System.Security.Cryptography;
public class WebhookServer
{
private static readonly string SecretKey = "my_signature_key_from_iDenfy"; // Replace with your actual key
public static void StartServer(string urlPrefix)
{
HttpListener listener = new HttpListener();
listener.Prefixes.Add(urlPrefix);
listener.Start();
Console.WriteLine($"Webhook server started and listening on {urlPrefix}");
while (true)
{
HttpListenerContext context = listener.GetContext();
HttpListenerRequest request = context.Request;
if (request.HttpMethod == "POST")
{
HandlePostRequest(context);
}
else
{
HandleNonPostRequest(context);
}
}
}
private static void HandlePostRequest(HttpListenerContext context)
{
try
{
using (StreamReader reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding))
{
string payload = reader.ReadToEnd();
string receivedSignature = context.Request.Headers["Idenfy-Signature"];
if (string.IsNullOrEmpty(receivedSignature))
{
SendResponse(context.Response, 400, "Idenfy-Signature header is missing");
return;
}
string computedSignature = ComputeSignature(payload);
Console.WriteLine($"Request signature: {receivedSignature}");
Console.WriteLine($"Calculated signature: {computedSignature}");
if (!CompareSignatures(computedSignature, receivedSignature))
{
SendResponse(context.Response, 400, "Invalid signature");
return;
}
Console.WriteLine("Valid signature");
SendResponse(context.Response, 200, "Valid signature received and processed");
}
}
catch (Exception ex)
{
Console.WriteLine($"Error handling request: {ex.Message}");
SendResponse(context.Response, 500, "Internal server error");
}
}
private static void HandleNonPostRequest(HttpListenerContext context)
{
// Handle requests that are not POST
context.Response.StatusCode = 200; // Change status code as needed
using (StreamWriter writer = new StreamWriter(context.Response.OutputStream))
{
writer.Write("This server only handles POST requests. Please use POST method.");
}
context.Response.Close();
}
private static string ComputeSignature(string payload)
{
byte[] keyBytes = Encoding.UTF8.GetBytes(SecretKey);
using (var hmac = new HMACSHA256(keyBytes))
{
byte[] payloadBytes = Encoding.UTF8.GetBytes(payload);
byte[] hashBytes = hmac.ComputeHash(payloadBytes);
return BitConverter.ToString(hashBytes).Replace("-", "").ToLower();
}
}
private static bool CompareSignatures(string computedSignature, string receivedSignature)
{
return string.Equals(computedSignature, receivedSignature, StringComparison.OrdinalIgnoreCase);
}
private static void SendResponse(HttpListenerResponse response, int statusCode, string message)
{
response.StatusCode = statusCode;
using (StreamWriter writer = new StreamWriter(response.OutputStream))
{
writer.Write(message);
}
response.Close();
}
public static void Main(string[] args)
{
string urlPrefix = "http://*:8080/";
StartServer(urlPrefix);
}
}