Overview

To protect your webhook endpoints from malicious requests, we’ve implemented RSA-SHA256 signature verification. Every webhook request from our system includes cryptographic signatures in the headers that you can verify using our public key to ensure the request genuinely came from us. Why This Matters: Without signature verification, anyone could send fake webhook requests to your endpoints. This system ensures only legitimate requests from our platform reach your application.

How It Works

We use RSA-SHA256 digital signatures with 2048-bit keys:
  1. We sign each webhook request with our private key
  2. You verify the signature using our public key
  3. If verification passes, you know the request is authentic

Request Headers

Every webhook request includes these security headers:
HeaderDescriptionExample
X-Webhook-SignatureBase64-encoded RSA signatureiJ0S7p8K2n...
X-Webhook-TimestampUnix timestamp (seconds)1704067200

Signature Construction

We create the signature by concatenating three components:
{timestamp}.{url}.{body_sha256_hex}
Example:
1704067200.https://api.yourapp.com/webhooks.a1b2c3d4e5f6...
Where:
  • timestamp: Request timestamp (matches X-Webhook-Timestamp)
  • url: Complete webhook URL (including query parameters)
  • body_sha256_hex: SHA256 hash of the request body in hex format

Get Our Public Key

API Endpoint

GET /v1/webhook/public_key

Response Format

{
  "public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\n-----END PUBLIC KEY-----",
  "algorithm": "RSA-SHA256",
  "created_at": "2025-01-01T00:00:00Z"
}
Pro Tip: Cache this public key! Don’t fetch it on every webhook request.

Implementation Examples

import base64
import hashlib
import time
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.exceptions import InvalidSignature

def verify_webhook_signature(
    public_key_pem: str,
    url: str,
    body: bytes,
    signature_b64: str,
    timestamp: str
) -> bool:
    """
    Verify webhook signature to ensure request authenticity.
    
    Args:
        public_key_pem: PEM-formatted public key
        url: Complete webhook URL
        body: Raw request body (bytes)
        signature_b64: Base64-encoded signature from X-Webhook-Signature header
        timestamp: Timestamp string from X-Webhook-Timestamp header
    
    Returns:
        True if signature is valid, False otherwise
    """
    
    # 1. Verify timestamp is recent (prevents replay attacks)
    current_time = int(time.time())
    request_time = int(timestamp)
    if abs(current_time - request_time) > 300:  # 5 minute window
        print(f"Request timestamp {request_time} is outside acceptable range")
        return False
    
    # 2. Reconstruct the signed content
    body_hash = hashlib.sha256(body).hexdigest()
    signature_content = f"{timestamp}.{url}.{body_hash}".encode('utf-8')
    
    # 3. Hash the content (this is what was actually signed)
    content_hash = hashlib.sha256(signature_content).digest()
    
    # 4. Load the public key
    try:
        public_key = serialization.load_pem_public_key(public_key_pem.encode())
    except Exception as e:
        print(f"Failed to load public key: {e}")
        return False
    
    # 5. Decode the signature
    try:
        signature = base64.b64decode(signature_b64)
    except Exception as e:
        print(f"Failed to decode signature: {e}")
        return False
    
    # 6. Verify the signature
    try:
        public_key.verify(
            signature,
            content_hash,
            padding.PKCS1v15(),
            hashes.SHA256()
        )
        return True
    except InvalidSignature:
        print("Signature verification failed")
        return False

# Flask/Django Example
def handle_webhook(request):
    """Example webhook handler with signature verification"""
    
    # Extract headers
    signature = request.headers.get('X-Webhook-Signature')
    timestamp = request.headers.get('X-Webhook-Timestamp')
    
    if not signature or not timestamp:
        return 400, "Missing required headers"
    
    # Get cached public key (implement your caching strategy)
    public_key = get_cached_public_key()
    
    # Verify the signature
    is_valid = verify_webhook_signature(
        public_key,
        request.url,
        request.body,
        signature,
        timestamp
    )
    
    if not is_valid:
        return 401, "Invalid signature"
    
    # Process your webhook logic here
    webhook_data = request.json
    process_webhook_event(webhook_data)
    
    return 200, "OK"

Security Best Practices

πŸ”‘ Always Cache the Public Key

# ❌ Don't do this - too slow and unnecessary
def handle_webhook(request):
    public_key = requests.get('/v1/webhook/public_key').json()['public_key']
    # ... verify signature

# βœ… Do this - cache with reasonable TTL
class PublicKeyCache:
    def __init__(self, cache_ttl=3600):  # 1 hour
        self._key = None
        self._last_fetch = 0
        self._ttl = cache_ttl
    
    def get_key(self):
        if time.time() - self._last_fetch > self._ttl:
            self._refresh_key()
        return self._key

πŸ• Always Verify Timestamps

# Prevents replay attacks - someone can't reuse old webhook requests
MAX_TIMESTAMP_AGE = 300  # 5 minutes

def is_timestamp_valid(timestamp_str):
    try:
        request_time = int(timestamp_str)
        current_time = int(time.time())
        age = abs(current_time - request_time)
        return age <= MAX_TIMESTAMP_AGE
    except ValueError:
        return False

πŸ”’ Use HTTPS Only

# Always ensure your webhook endpoints use HTTPS
if not request.is_secure:
    return 400, "HTTPS required"

🚫 Don’t Leak Error Details

# ❌ Don't expose internal details
if not verify_signature():
    return 401, f"Signature verification failed: {detailed_error}"

# βœ… Generic error messages
if not verify_signature():
    logger.warning(f"Invalid signature from {request.remote_addr}")
    return 401, "Unauthorized"