How to design secure Salesforce CRM webhook listeners using signature validation and payload queues
Securing Salesforce Webhook Ingress with Signature Validation
When integrating external systems with Salesforce via webhooks, a critical security concern is ensuring the authenticity and integrity of incoming data. Salesforce provides a robust mechanism for this: signed requests. By validating these signatures on your listener endpoint, you can prevent unauthorized systems from sending malicious or erroneous data into your Salesforce instance. This section details how to implement this validation in a PHP-based listener.
Salesforce signs webhook payloads using a shared secret (your Connected App’s “Consumer Secret”). The signature is generated by concatenating the entire request body with the Consumer Secret, then hashing the result using HMAC-SHA256. The resulting hash is Base64 encoded and sent in the X-Salesforce-Signature HTTP header.
Prerequisites
- A Salesforce Connected App configured with OAuth 2.0, and its “Consumer Key” and “Consumer Secret” readily available.
- A publicly accessible webhook listener endpoint (e.g., a PHP script on your web server).
- The Salesforce Outbound Message or Platform Event configuration pointing to your listener URL.
PHP Implementation for Signature Validation
The core of the validation process involves retrieving the signature from the request header, reconstructing the signed string, and comparing the computed hash with the provided signature. Here’s a production-ready PHP snippet:
<?php
// --- Configuration ---
// IMPORTANT: Store your Consumer Secret securely, e.g., via environment variables or a secrets manager.
// NEVER hardcode it directly in production code.
$consumerSecret = getenv('SALESFORCE_CONSUMER_SECRET');
if (!$consumerSecret) {
// Log an error and exit if the secret is not configured.
error_log("SALESFORCE_CONSUMER_SECRET environment variable not set.");
http_response_code(500); // Internal Server Error
exit("Server configuration error.");
}
// --- Get Request Data ---
$requestBody = file_get_contents('php://input');
$signatureHeader = $_SERVER['HTTP_X_SALESFORCE_SIGNATURE'] ?? null;
// --- Basic Validation Checks ---
if (empty($requestBody)) {
error_log("Received empty request body.");
http_response_code(400); // Bad Request
exit("Invalid request: Empty body.");
}
if (empty($signatureHeader)) {
error_log("Missing X-Salesforce-Signature header.");
http_response_code(400); // Bad Request
exit("Invalid request: Missing signature header.");
}
// --- Signature Verification ---
// Salesforce signs the raw request body concatenated with the Consumer Secret.
$expectedSignature = base64_encode(hash_hmac('sha256', $requestBody, $consumerSecret, true));
// Compare the computed signature with the one provided in the header.
// Use hash_equals for constant-time comparison to prevent timing attacks.
if (!hash_equals($expectedSignature, $signatureHeader)) {
error_log("Signature mismatch. Expected: {$expectedSignature}, Received: {$signatureHeader}");
http_response_code(401); // Unauthorized
exit("Invalid signature.");
}
// --- Payload Processing (if signature is valid) ---
// At this point, you can trust the data.
// Decode the JSON payload and process it.
$payload = json_decode($requestBody, true);
if (json_last_error() !== JSON_ERROR_NONE) {
error_log("Failed to decode JSON payload: " . json_last_error_msg());
http_response_code(400); // Bad Request
exit("Invalid JSON payload.");
}
// --- Placeholder for your actual business logic ---
// Example: Log the received data or enqueue it for background processing.
error_log("Successfully validated webhook signature. Payload received: " . print_r($payload, true));
// Respond to Salesforce with a 200 OK to acknowledge receipt.
http_response_code(200);
echo "Webhook received and validated successfully.";
?>
Key points in this script:
- Secure Secret Management: The
$consumerSecretis retrieved from an environment variable. This is crucial for security; never hardcode sensitive credentials. - Raw Body Retrieval:
file_get_contents('php://input')is used to get the raw, unparsed request body, which is what Salesforce signs. - Header Extraction: The
X-Salesforce-Signatureheader is accessed via$_SERVER['HTTP_X_SALESFORCE_SIGNATURE']. - HMAC-SHA256 Hashing:
hash_hmac('sha256', $requestBody, $consumerSecret, true)computes the hash. Thetrueparameter indicates that the raw binary output should be returned. - Base64 Encoding: The raw hash is then Base64 encoded using
base64_encode()to match the format in the header. - Constant-Time Comparison:
hash_equals()is used to compare the computed signature with the received one. This is vital to prevent timing attacks, where an attacker could infer information about the secret by measuring the time it takes for the comparison to fail. - Error Handling: Appropriate HTTP response codes (400, 401, 500) and error logging are included for debugging and security.
- JSON Decoding: After successful validation, the payload is decoded.
Implementing a Robust Payload Queue for Asynchronous Processing
While signature validation protects against unauthorized access, relying solely on synchronous processing of webhook payloads can lead to timeouts, especially if Salesforce’s timeout limits are strict or if your processing logic is complex. A more resilient approach is to enqueue the validated payload for asynchronous processing. This ensures that Salesforce receives a quick acknowledgment (200 OK), while your application handles the data at its own pace.
Choosing a Queueing System
Several options exist for implementing a message queue:
- Redis: Excellent for simple, fast queues. Libraries like Predis or PhpRedis make integration straightforward.
- RabbitMQ / Kafka: More robust, feature-rich message brokers suitable for complex distributed systems, offering features like guaranteed delivery, routing, and persistence.
- Database-backed Queues: Using a dedicated table in your primary database can be a simpler starting point, though it might impact database performance under heavy load.
For this example, we’ll illustrate using Redis due to its commonality and ease of setup for many web applications.
Integrating Redis for Payload Queuing (PHP)
First, ensure you have the predis/predis library installed via Composer:
composer require predis/predis
Now, modify the webhook listener script to push validated payloads onto a Redis queue:
<?php
require 'vendor/autoload.php'; // Include Composer's autoloader
// --- Configuration ---
$consumerSecret = getenv('SALESFORCE_CONSUMER_SECRET');
if (!$consumerSecret) {
error_log("SALESFORCE_CONSUMER_SECRET environment variable not set.");
http_response_code(500);
exit("Server configuration error.");
}
// Redis connection details (use environment variables for production)
$redisHost = getenv('REDIS_HOST') ?: '127.0.0.1';
$redisPort = getenv('REDIS_PORT') ?: 6379;
$redisQueueName = 'salesforce_webhook_queue'; // Name of the Redis list to use as a queue
// --- Redis Connection ---
try {
$redis = new Predis\Client([
'scheme' => 'tcp',
'host' => $redisHost,
'port' => $redisPort,
]);
$redis->connect();
} catch (Exception $e) {
error_log("Failed to connect to Redis: " . $e->getMessage());
http_response_code(500);
exit("Queueing service unavailable.");
}
// --- Get Request Data ---
$requestBody = file_get_contents('php://input');
$signatureHeader = $_SERVER['HTTP_X_SALESFORCE_SIGNATURE'] ?? null;
// --- Basic Validation Checks ---
if (empty($requestBody)) {
error_log("Received empty request body.");
http_response_code(400);
exit("Invalid request: Empty body.");
}
if (empty($signatureHeader)) {
error_log("Missing X-Salesforce-Signature header.");
http_response_code(400);
exit("Invalid request: Missing signature header.");
}
// --- Signature Verification ---
$expectedSignature = base64_encode(hash_hmac('sha256', $requestBody, $consumerSecret, true));
if (!hash_equals($expectedSignature, $signatureHeader)) {
error_log("Signature mismatch. Expected: {$expectedSignature}, Received: {$signatureHeader}");
http_response_code(401);
exit("Invalid signature.");
}
// --- Payload Processing (if signature is valid) ---
$payload = json_decode($requestBody, true);
if (json_last_error() !== JSON_ERROR_NONE) {
error_log("Failed to decode JSON payload: " . json_last_error_msg());
http_response_code(400);
exit("Invalid JSON payload.");
}
// --- Enqueue Payload to Redis ---
try {
// Store the payload as a JSON string in the Redis list.
// LPUSH adds the element to the head of the list.
$redis->lpush($redisQueueName, json_encode($payload));
error_log("Successfully validated webhook signature and enqueued payload to Redis queue '{$redisQueueName}'.");
// Respond to Salesforce with a 200 OK immediately.
http_response_code(200);
echo "Webhook received and queued for processing.";
} catch (Exception $e) {
// If queuing fails, log the error but still respond with 200 OK to Salesforce
// to avoid Salesforce retries for a valid, but unqueued, message.
// A separate monitoring system should alert on Redis queueing failures.
error_log("Failed to enqueue payload to Redis: " . $e->getMessage());
http_response_code(200); // Still acknowledge to Salesforce
echo "Webhook received, but queuing failed. Please check system logs.";
}
?>
Worker Script for Asynchronous Processing
You’ll need a separate script (a “worker”) that continuously polls the Redis queue and processes messages. This script can be run as a long-running process (e.g., using Supervisor or systemd).
<?php
require 'vendor/autoload.php'; // Include Composer's autoloader
// --- Configuration ---
$redisHost = getenv('REDIS_HOST') ?: '127.0.0.1';
$redisPort = getenv('REDIS_PORT') ?: 6379;
$redisQueueName = 'salesforce_webhook_queue'; // Must match the queue name in the listener
// --- Redis Connection ---
try {
$redis = new Predis\Client([
'scheme' => 'tcp',
'host' => $redisHost,
'port' => $redisPort,
]);
$redis->connect();
} catch (Exception $e) {
die("Failed to connect to Redis: " . $e->getMessage() . "\n");
}
echo "Worker started. Listening on queue: {$redisQueueName}\n";
// --- Processing Loop ---
while (true) {
// BLPOP is a blocking list pop primitive. It waits for an element to appear
// in the list and then pops it. The second argument is the timeout in seconds.
// A timeout of 0 means it will block indefinitely.
$result = $redis->blpop([$redisQueueName], 0); // Block indefinitely
if ($result) {
$queueName = $result[0]; // The key of the list (e.g., 'salesforce_webhook_queue')
$payloadJson = $result[1]; // The popped element (the JSON string)
echo "Processing item from {$queueName}...\n";
$payload = json_decode($payloadJson, true);
if (json_last_error() === JSON_ERROR_NONE) {
// --- YOUR ACTUAL BUSINESS LOGIC GOES HERE ---
// This is where you'd interact with your database,
// call other APIs, update records, etc.
try {
processSalesforcePayload($payload); // Implement this function
echo "Successfully processed payload.\n";
} catch (Exception $e) {
// Handle processing errors. Depending on requirements, you might:
// 1. Log the error and discard the message (if retries are handled by Salesforce).
// 2. Move the message to a "dead-letter queue" for manual inspection.
// 3. Re-queue the message with a delay (careful not to create infinite loops).
error_log("Error processing payload: " . $e->getMessage() . "\nPayload: " . print_r($payload, true));
// For simplicity, we'll just log and continue.
}
// --- END BUSINESS LOGIC ---
} else {
error_log("Failed to decode JSON payload from queue: " . json_last_error_msg() . "\nRaw data: " . $payloadJson);
// Decide how to handle malformed messages in the queue.
}
}
// No sleep needed because blpop is blocking.
}
/**
* Placeholder function for your actual payload processing logic.
* This function should contain the core business logic that interacts
* with your application's data and services.
*
* @param array $payload The decoded Salesforce webhook payload.
* @throws Exception If processing fails.
*/
function processSalesforcePayload(array $payload) {
// Example:
// echo "Processing order data...\n";
// $orderId = $payload['data']['Id'];
// $orderStatus = $payload['data']['Status'];
// updateOrderStatusInDatabase($orderId, $orderStatus);
// sendNotification($orderId, $orderStatus);
// For demonstration, we'll just simulate work.
sleep(2); // Simulate a 2-second processing time
// If any of these operations fail, throw an Exception.
// For example: throw new Exception("Database update failed for order {$orderId}");
}
?>
Running the Worker:
- Save the worker script (e.g.,
worker.php). - Run it from your server’s command line:
php worker.php. - For production, use a process manager like Supervisor to ensure the worker restarts automatically if it crashes and runs continuously. A basic Supervisor configuration might look like this:
[program:salesforce_webhook_worker] process_name=%(program_name)s_%(process_num)02d command=php /path/to/your/worker.php autostart=true autorestart=true user=your_user numprocs=1 redirect_stderr=true stdout_logfile=/var/log/supervisor/salesforce_webhook_worker.log
By implementing signature validation and a robust queuing mechanism, you create a secure, reliable, and scalable integration point for Salesforce webhooks, ensuring data integrity and preventing system overload.