Payload Signing and Verification
Why This Approach Matters
Securing webhook communications in distributed systems is critical to prevent unauthorized access and ensure data integrity. This document details our recommended approach for verifying webhook payloads and signatures to guarantee that data is both authentic and untampered. Our webhook verification method resolves these vulnerabilities by leveraging HMAC (Hash-based Message Authentication Code) signatures combined with strict timestamp validation. Webhook verification ensures three critical security guarantees: message authenticity - confirms webhooks come only from our systems, data integrity - verifies content hasn't been modified, and timestamp validation - prevents replay attacks. By following the steps in this guide, you can confidently validate incoming webhook requests and prevent potential security threats.
HTTP Headers Overview
Header Name | Purpose | Format | Example Value |
|---|---|---|---|
Founda-Signature | Contains the HMAC SHA256 signature | sha256=<base64-encoded signature> May include multiple signatures, separated by commas. | sha256=abcdef123…,sha256=ghijk456… |
Founda-Signed-Headers | Lists headers used in signature calculation | The header values are space-delimited and must be provided in the exact order used for signature generation. The Founda-Timestamp header is always included. Founda-Signed-Headers is always the last item in the list. | founda-timestamp founda-signed-headers |
Founda-Timestamp | Provides the request timestamp (RFC 3339) | RFC 3339 date-time string | 2025-03-19T12:34:56Z |
Note: While HTTP headers are case-insensitive, convert all header names to lowercase when constructing the canonical string
Verifying webhook requests
You can follow these steps to verify our webhook requests.
Creating the Canonical String for Verification
To verify the request signature, reconstruct the canonical representation to be signed using the following steps:
- Begin with the exact request URL (including query parameters, if applicable).
- Append a newline character (\n).
- For each header listed in Founda-Signed-Headers (in the given order):
- Convert the header name to lowercase.
- Append a colon (:) immediately followed by the header value (with no extra spaces).
- If a header appears multiple times, concatenate its values using , (comma + space), preserving the original order.
- Append a literal LF newline character (ASCII 10, represented as \n). Do not use CRLF or any other newline variant.
- Append the raw request body (if applicable).
Important: Most web frameworks modify the request body during parsing. Ensure you capture the original, unmodified request body string for signature verification.
The exact string representation is critical — any deviation in whitespace, casing, or newline characters will cause verification to fail.
Example
Consider the following request:
The constructed canonical string would be:
This string is then used for signature verification.
Signature Generation
Once the payload string is constructed, generate the signature as follows:
- Compute the HMAC Signature
- Apply the HMAC SHA256 algorithm to the constructed canonical string using the webhook secret key provided during integration. Ensure that the secret key remains confidential.
- After applying HMAC SHA256, base64-encode the binary digest before comparison.
- Compare the Signature
- Extract the base64-encoded signature from the Founda-Signature header.
- Extract the signature by removing the sha256= prefix (including the equals sign) and, if multiple signatures are present, split by commas. Validate against each signature.
- Perform a constant-time comparison to prevent timing attacks.
- If the signatures do not match, reject the request with an HTTP 400 Bad Request response.
Note: Use a constant-time comparison function to compare the calculated signature with the received one to mitigate timing attacks.
Timestamp Validation
To prevent replay attacks, validate the Founda-Timestamp header:
- Parse the timestamp and compare it to the current time.
- If the difference exceeds 5 minutes, reject the request with an HTTP 400 Bad Request response.
This ensures that old (potentially intercepted) requests cannot be replayed.
Handling Multiple Secrets
In scenarios where multiple secret keys are active (e.g., during a key rotation), requests are signed with each active key. The Founda-Signature header will contain multiple signatures, separated by commas.
When verifying the request:
- Compute the expected signature using each active secret.
- Accept the request if at least one of the signatures matches.
Validating the Signature (JavaScript Example)
The following, simplified example demonstrates how to validate an incoming webhook request signature using Node.js:
const crypto = require('crypto');
function isValidSignature(requestUrl, headers, requestBody, secrets) {
const signedHeaders = headers['Founda-Signed-Headers'];
const timestamp = headers['Founda-Timestamp'];
const signature = headers['Founda-Signature'];
const headersLowercase = {
'founda-signed-headers': signedHeaders,
'founda-timestamp': timestamp,
'founda-signature': signature
};
// Check for required headers
if (!timestamp || !signedHeaders || !signature) {
return false;
}
// Validate timestamp
const fiveMinutes = 5 * 60 * 1000;
const requestTime = new Date(timestamp).getTime();
const currentTime = Date.now();
if (Math.abs(currentTime - requestTime) > fiveMinutes) {
return false; // Timestamp outside the allowed window
}
// Construct canonical string
let payloadString = requestUrl + '\n';
for (let headerName of signedHeaders.split(' ')) {
payloadString +=
headerName.toLowerCase() + ':' + headersLowercase[headerName] + '\n';
}
payloadString += requestBody;
// Extract received signatures
const receivedSignatures = signature
.split(',')
.map(sig => Buffer.from(sig.replace('sha256=', '').trim()));
// Check against all active secrets
for (const secret of secrets) {
const hmac = crypto.createHmac('sha256', secret);
hmac.update(payloadString);
const expectedSignature = Buffer.from(hmac.digest('base64'));
// Check if any signature matches
const signatureValid = receivedSignatures.some(sig =>
sig.length === expectedSignature.length && crypto.timingSafeEqual(sig, expectedSignature)
);
if (signatureValid) {
return true;
}
}
return false;
}
// isValidSignature usage example
isValidSignature(
'https://webhook-server.com/webhook/v1',
{
'Founda-Signature': 'sha256=BsB...',
'Founda-Signed-Headers': 'founda-timestamp founda-signed-headers',
'Founda-Timestamp': '2025-04-01T15:16:30.083Z'
},
'{"resourceType":"Bundle","type":"batch", ...',
['5c1...']
);To use this function:
- Extract the Founda-Signature, Founda-Signed-Headers, and Founda-Timestamp headers.
- Pass the request path, headers, raw request body, and your webhook secret to isValidSignature().
- If the function returns false, reject the request.
Response Codes
- HTTP 200 OK
- You should return this response if all verification steps succeed:
- Required headers are present.
- Signature validation passes.
- Timestamp is within the allowed window.
- HTTP 400 Bad Request
- You should return this response if the request is invalid. Your response should include a JSON object specifying the error:
{
"error": "Invalid Request",
"message": "The 'Founda-Signature' header is missing."
}Common reasons for rejecting a request:
- A required header is missing.
- Signature verification fails.
- The timestamp is outside the allowed time window.
Important: These error responses should be sent by your application to us when a request is rejected. Our system does not automatically generate these responses, so it is your responsibility to implement and return them when necessary.