Verify Genesys Cloud Webhook Signatures to Prevent Replay Attacks
What You Will Build
- You will build a Python HTTP endpoint that receives an event from Genesys Cloud CX and validates the
X-Genesys-Signatureheader. - You will use the
hmacandhashlibstandard library modules to reproduce the HMAC-SHA256 signature and compare it against the received header. - You will implement a non-repeating nonce check to reject replay attacks where an attacker resends a valid, old request.
Prerequisites
- Platform: Genesys Cloud CX
- API/SDK: Genesys Cloud Webhooks API (
/api/v2/webhooks) and standard Pythonhttp.serveror Flask/FastAPI. - Language: Python 3.8+
- Dependencies: No external libraries are required for the signature verification logic itself, as
hmacandhashlibare part of the standard library. For the HTTP server example, this tutorial uses the standard libraryhttp.serverto minimize dependencies, but the logic applies equally to Flask, FastAPI, or Django. - Webhook Secret: You must have the
secretvalue from your Genesys Cloud Webhook configuration. This is visible when you create or edit a webhook in the Admin Console under the “Security” or “Headers” section.
Authentication Setup
Webhook verification does not use OAuth tokens in the traditional sense (no client_id/client_secret flow for the receiver). Instead, it relies on a shared secret established during webhook creation.
- Create the Webhook: In the Genesys Cloud Admin Console, navigate to Integrations > Webhooks. Create a new webhook pointing to your endpoint.
- Set the Secret: In the webhook configuration, locate the Secret field. Generate a strong random string (at least 32 characters). This string is your private key for HMAC verification.
- Note the Secret: Copy this value securely. You will need it in your code as
WEBHOOK_SECRET.
Critical Note: Genesys Cloud sends the signature in the X-Genesys-Signature header. The payload signed is the raw request body. You must not modify the body (e.g., via middleware that alters JSON) before verifying the signature.
Implementation
Step 1: Extracting Headers and Payload
The first step is to capture the raw request body and the signature header. In many web frameworks, the body is parsed into JSON automatically. This breaks signature verification because the parsing process may change whitespace, encoding, or order. You must access the raw bytes.
Python Standard Library Example:
import json
import hmac
import hashlib
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
WEBHOOK_SECRET = "your_super_secret_key_here"
HOST_NAME = "localhost"
PORT_NUMBER = 8080
class WebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
# 1. Get the content length to read the exact body size
content_length = int(self.headers.get('Content-Length', 0))
# 2. Read the RAW body bytes
# This is crucial. Do not decode to string yet.
post_body = self.rfile.read(content_length)
# 3. Extract the signature header
# Genesys Cloud sends this header for every webhook event
signature_header = self.headers.get('X-Genesys-Signature')
if not signature_header:
self.send_error(400, "Missing X-Genesys-Signature header")
return
# Proceed to verification in Step 2
self.verify_and_process(post_body, signature_header)
Why Raw Bytes?
The HMAC is calculated over the exact byte sequence sent. If you decode post_body to a UTF-8 string, then re-encode it, you might introduce subtle differences if the original encoding was not standard UTF-8 or if there are BOM (Byte Order Mark) issues. Keeping it as bytes ensures the input to your hash function matches exactly what Genesys Cloud signed.
Step 2: Calculating and Verifying the Signature
Genesys Cloud uses HMAC-SHA256. The message is the raw request body. The key is your webhook secret.
The Verification Logic:
def verify_and_process(self, payload_bytes: bytes, received_signature: str):
"""
Verifies the HMAC-SHA256 signature of the webhook payload.
"""
# 1. Encode the secret to bytes if it is a string
secret_bytes = WEBHOOK_SECRET.encode('utf-8')
# 2. Calculate the HMAC-SHA256
# hmac.new(key, msg, digestmod)
hmac_signature = hmac.new(
secret_bytes,
payload_bytes,
hashlib.sha256
).hexdigest()
# 3. Secure Comparison
# Use hmac.compare_digest to prevent timing attacks
# A simple == comparison can leak information via timing differences
if not hmac.compare_digest(hmac_signature, received_signature):
self.send_error(401, "Invalid signature")
return
# Signature is valid. Proceed to processing.
self.process_valid_webhook(payload_bytes)
Security Detail: Timing Attacks
Using hmac.compare_digest is mandatory. A standard == operator in Python short-circuits on the first mismatched byte. An attacker could measure the response time to determine how many leading bytes of their fake signature match the real one, eventually brute-forcing the signature. compare_digest always compares the full length of both strings, making the execution time constant regardless of where the mismatch occurs.
Step 3: Preventing Replay Attacks with Nonce Validation
A valid signature only proves the message came from someone who knows the secret. It does not prove the message is fresh. An attacker could record a valid webhook request (e.g., “Transfer $1000”) and resend it 1,000 times. This is a replay attack.
Genesys Cloud webhooks include a timestamp field in the payload and often allow you to include a nonce (number used once) or rely on the event_id.
Strategy:
- Timestamp Check: Reject requests older than a short window (e.g., 5 minutes).
- Idempotency/Nonce Check: Track processed
event_idvalues. Reject duplicates.
Implementation:
import sqlite3
import threading
# Simple in-memory or DB-backed nonce tracker
# In production, use Redis or a database with TTL
class NonceStore:
def __init__(self):
# Using a thread-safe set for demonstration
self._lock = threading.Lock()
self._processed_ids = set()
self._cleanup_threshold = 10000 # Max IDs to keep in memory
def is_duplicate(self, event_id: str) -> bool:
with self._lock:
if event_id in self._processed_ids:
return True
self._processed_ids.add(event_id)
# Basic cleanup to prevent memory leak in demo
if len(self._processed_ids) > self._cleanup_threshold:
# In production, use a DB with TTL or Redis EXPIRE
self._processed_ids.clear()
return False
nonce_store = NonceStore()
def process_valid_webhook(self, payload_bytes: bytes):
try:
# Decode only after signature verification
payload = json.loads(payload_bytes.decode('utf-8'))
except json.JSONDecodeError:
self.send_error(400, "Invalid JSON")
return
# 1. Extract Event ID (Genesys Cloud provides this in the payload)
# Structure varies by event type, but typically:
event_id = payload.get("event_id") or payload.get("id")
if not event_id:
self.send_error(400, "Missing event_id")
return
# 2. Check for Replay (Duplicate Event ID)
if nonce_store.is_duplicate(event_id):
self.send_error(409, "Duplicate event_id: Replay attack detected")
return
# 3. Timestamp Validation (Optional but recommended)
# Genesys payloads often include a 'timestamp' or 'event_date'
# Check if the event is older than 5 minutes (300 seconds)
event_timestamp = payload.get("timestamp")
if event_timestamp:
try:
event_time = float(event_timestamp)
current_time = time.time()
if current_time - event_time > 300:
self.send_error(400, "Event is too old")
return
except ValueError:
pass # Ignore malformed timestamps
# 4. Process the valid, fresh webhook
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
response_body = json.dumps({"status": "success", "event_id": event_id})
self.wfile.write(response_body.encode('utf-8'))
print(f"Processed event: {event_id}")
Why This Works:
- Signature Check: Ensures the data was not tampered with in transit.
- Nonce/ID Check: Ensures the data has not been processed before.
- Timestamp Check: Ensures the data is not excessively stale (adds a layer of defense against delayed replays).
Complete Working Example
This is a full, runnable Python script using the standard library. It starts an HTTP server on port 8080.
"""
Genesys Cloud Webhook Verifier
Verifies X-Genesys-Signature and prevents replay attacks.
"""
import json
import hmac
import hashlib
import time
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
# CONFIGURATION
WEBHOOK_SECRET = "your_super_secret_key_here" # REPLACE THIS
HOST_NAME = "localhost"
PORT_NUMBER = 8080
class NonceStore:
"""Thread-safe store for processed event IDs to prevent replays."""
def __init__(self):
self._lock = threading.Lock()
self._processed_ids = set()
self._max_size = 10000
def is_duplicate(self, event_id: str) -> bool:
with self._lock:
if event_id in self._processed_ids:
return True
self._processed_ids.add(event_id)
if len(self._processed_ids) > self._max_size:
# Warning: In production, use a database with TTL or Redis.
# Clearing memory here is just for demo stability.
self._processed_ids.clear()
return False
nonce_store = NonceStore()
class GenesysWebhookHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
# Suppress default logging for cleaner output
pass
def do_POST(self):
content_length = int(self.headers.get('Content-Length', 0))
# Read raw body bytes
post_body = self.rfile.read(content_length)
# Get signature header
signature_header = self.headers.get('X-Genesys-Signature')
if not signature_header:
self.send_error(400, "Missing X-Genesys-Signature header")
return
# Verify Signature
if not self.verify_signature(post_body, signature_header):
self.send_error(401, "Invalid signature")
return
# Process Valid Webhook
self.process_webhook(post_body)
def verify_signature(self, payload_bytes: bytes, received_signature: str) -> bool:
"""
Computes HMAC-SHA256 and compares it with the received signature.
Returns True if valid, False otherwise.
"""
secret_bytes = WEBHOOK_SECRET.encode('utf-8')
# Calculate HMAC
computed_signature = hmac.new(
secret_bytes,
payload_bytes,
hashlib.sha256
).hexdigest()
# Secure comparison
return hmac.compare_digest(computed_signature, received_signature)
def process_webhook(self, payload_bytes: bytes):
"""
Handles the business logic after successful verification.
Includes replay attack prevention.
"""
try:
payload = json.loads(payload_bytes.decode('utf-8'))
except json.JSONDecodeError:
self.send_error(400, "Invalid JSON payload")
return
# Extract Event ID
# Genesys Cloud event structures vary.
# Common fields: 'event_id', 'id', or nested in 'event'
event_id = payload.get("event_id") or payload.get("id")
if not event_id:
self.send_error(400, "Missing event_id in payload")
return
# Check for Replay
if nonce_store.is_duplicate(event_id):
self.send_error(409, f"Replay attack detected: Duplicate event_id {event_id}")
return
# Optional: Timestamp Check
# Genesys payloads often include 'timestamp' or 'event_date'
# This example checks for a generic 'timestamp' field
timestamp_str = payload.get("timestamp")
if timestamp_str:
try:
event_time = float(timestamp_str)
if time.time() - event_time > 300: # 5 minutes
self.send_error(400, "Event timestamp is too old")
return
except (ValueError, TypeError):
pass # Ignore if timestamp is malformed
# SUCCESS: Process the event
# Example: Save to DB, trigger action, etc.
print(f"[SUCCESS] Processed Event ID: {event_id}")
# Respond to Genesys Cloud
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
response_body = json.dumps({"status": "ok", "event_id": event_id})
self.wfile.write(response_body.encode('utf-8'))
if __name__ == "__main__":
print(f"Starting server on {HOST_NAME}:{PORT_NUMBER}")
print(f"Waiting for webhooks... (Secret: {WEBHOOK_SECRET[:5]}...)")
server = HTTPServer((HOST_NAME, PORT_NUMBER), GenesysWebhookHandler)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down server...")
server.server_close()
Common Errors & Debugging
Error: 401 Invalid Signature
Cause: The computed HMAC does not match the X-Genesys-Signature header.
How to Fix:
- Check Secret Encoding: Ensure your
WEBHOOK_SECRETstring is encoded to UTF-8 bytes before passing tohmac.new. - Check Raw Body: Ensure you are signing the exact bytes received. If you are using a framework like Flask, use
request.get_data()instead ofrequest.json.request.jsonparses the JSON, which may alter the byte representation. - Check Header Name: Genesys Cloud uses
X-Genesys-Signature. Some older docs or other providers useX-Hub-Signature. Ensure you are reading the correct header.
Debug Code:
# Add this to your verify_signature method for debugging
print(f"Received Sig: {received_signature}")
print(f"Computed Sig: {computed_signature}")
print(f"Payload Bytes: {payload_bytes[:50]}...") # Log first 50 bytes
Error: 409 Replay Attack Detected
Cause: The event_id has already been processed by your system.
How to Fix:
- Check ID Extraction: Ensure you are extracting the correct unique identifier. For Genesys Cloud,
event_idis usually unique per event. If you are using a different field, ensure it is truly unique. - Check Time Window: If you are using a time-based window (e.g., only check last 5 minutes), ensure your clock is synchronized. If the clock is skewed, you might reject valid events or accept old ones.
- Network Retries: Genesys Cloud may retry failed webhooks. If your endpoint returns a 5xx error, Genesys will retry. If your endpoint returns 200, it will not retry. Ensure you return 200 after you have successfully saved the event to your database. If you save to DB and then crash before sending 200, Genesys will retry, and you will see a “Duplicate” error. Handle this gracefully (idempotency).
Error: 400 Event Timestamp is Too Old
Cause: The timestamp in the payload is more than 300 seconds older than the current server time.
How to Fix:
- Check Server Time: Ensure your server’s clock is accurate (use NTP).
- Adjust Window: If your network latency is high, increase the window from 300 to 600 seconds.
- Verify Payload Structure: Ensure you are reading the correct timestamp field. Some Genesys events use
event_dateinstead oftimestamp.
Official References
- Genesys Cloud Webhooks API Documentation
- Genesys Cloud Security: Webhook Signature Verification
- Python hmac Documentation
- OWASP: Preventing Replay Attacks (See section on Nonces and Timestamps)