Mailgun Webhook Signature Verification Gotchas

Integrating with third-party services often involves webhooks – automated HTTP POST requests sent to your application when a specific event occurs. Mailgun, a popular transactional email service, uses webhooks extensively for events like email delivery, bounces, clicks, and opens. While incredibly useful, consuming webhooks securely is paramount. Anyone could, in theory, send a POST request to your webhook endpoint. Without proper verification, your application might process malicious or spoofed data, leading to security vulnerabilities or incorrect behavior.

This is where signature verification comes in. Mailgun, like many other services, includes a cryptographic signature with each webhook request. Your job is to verify this signature to ensure two things: 1. The request genuinely originated from Mailgun. 2. The request payload has not been tampered with in transit.

Sounds straightforward, right? In practice, however, there are several common pitfalls that can trip up even experienced engineers. This article will walk you through the Mailgun signature verification process and highlight the "gotchas" that often lead to frustrating debugging sessions.

The Basics: How Mailgun Signatures Work

Every Mailgun webhook request includes three key pieces of information in its POST parameters, nested under the signature object: * timestamp: A Unix timestamp (integer) indicating when the webhook was sent. * token: A randomly generated string unique to each webhook event. * signature: The HMAC-SHA256 hash that you need to verify.

To verify the signature, you perform the following steps: 1. Retrieve your Mailgun API key. This is your secret. 2. Concatenate the timestamp and token values in that specific order. 3. Compute an HMAC-SHA256 hash of this concatenated string using your Mailgun API key as the secret key. 4. Compare your computed hash with the signature provided by Mailgun. If they match, the webhook is valid.

Setting Up Your Verification Environment

First, you'll need your Mailgun API key. This is your private API key, which typically starts with key-. You can find it in your Mailgun control panel under "API Keys". Do not use your public API key or SMTP credentials for this.

Let's look at a basic Python example for how to perform the verification.

import hmac
import hashlib
import time

MAILGUN_API_KEY = "YOUR_MAILGUN_PRIVATE_API_KEY" # e.g., 'key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'

def verify_mailgun_signature(timestamp, token, signature):
    """
    Verifies the Mailgun webhook signature.
    """
    # 1. Concatenate timestamp and token
    # Mailgun expects these as strings, so ensure they are.
    message = f"{timestamp}{token}"

    # 2. Compute HMAC-SHA256 hash
    # The API key and message must be bytes for hmac.new
    hmac_digest = hmac.new(
        key=MAILGUN_API_KEY.encode('utf-8'),
        msg=message.encode('utf-8'),
        digestmod=hashlib.sha256
    ).hexdigest()

    # 3. Compare with Mailgun's provided signature
    return hmac_digest == signature

# Example usage (replace with actual values from a webhook request)
# These values would come from request.form['signature']['timestamp'], etc.
# For testing, you'd capture a real Mailgun webhook.
incoming_timestamp = "1678886400"  # Example timestamp
incoming_token = "abcdef1234567890abcdef1234567890" # Example token
incoming_signature = "a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1" # Example signature

if verify_mailgun_signature(incoming_timestamp, incoming_token, incoming_signature):
    print("Mailgun signature verified successfully!")
else:
    print("Mailgun signature verification FAILED!")

This basic structure covers the core logic. Now, let's dive into the common pitfalls.

Gotcha #1: The API Key Mix-up

This is arguably the most frequent cause of signature verification failures. Mailgun provides several types of API keys: * Public Validation Key: Used for client-side email validation. * Private API Key (Secret Key): Starts with key- and is used for server-side operations, including sending emails and webhook signature verification. * SMTP Password: Used for sending emails via SMTP.

The Gotcha: You must use your Private API Key (the one starting with key-) as the secret for the HMAC hashing. Using any other key will result in a mismatch, and your verification will consistently fail.

How to avoid it: Double-check the key you're using. Go to your Mailgun dashboard, navigate to "API Keys," and copy the "Private API Key." Ensure it's exactly what you're using in your code.

Gotcha #2: Timestamp Skew and Replay Attacks

While signature verification confirms authenticity, the timestamp parameter serves an additional security purpose: preventing replay attacks. A replay attack occurs when an attacker intercepts a legitimate webhook, then resends it to your endpoint later. Without a timestamp check, your application might process the same event multiple times or act on outdated information.

Mailgun includes the timestamp for a reason. You should implement a check to ensure the timestamp is "fresh" enough. A common practice is to allow a window of a few minutes (e.g., 5 minutes) around the current server time.

# ... (previous verify_mailgun_signature function) ...

def verify_mailgun_signature_with_timestamp_check(timestamp_str, token, signature, max_age_seconds=300):
    """
    Verifies the Mailgun webhook signature and checks timestamp freshness.
    """
    try:
        timestamp_int = int(timestamp_str)
    except ValueError:
        print("Invalid timestamp format.")
        return False

    current_time = int(time.time())

    # Check for timestamp freshness (e.g., within 5 minutes = 300 seconds)
    if not (current_time - max_age_seconds <= timestamp_int <= current_time + max_age_seconds):
        print(f"Timestamp out of range. Received: {timestamp_int}, Current: {current_time}")
        return False

    # Proceed with signature verification if timestamp is fresh
    return verify_mailgun_signature(timestamp_str, token, signature)

# Example usage
# ... (same incoming_timestamp, token, signature as before) ...

if verify_mailgun_signature_with_timestamp_check(incoming_timestamp, incoming_token, incoming_signature):
    print("Mailgun signature and timestamp verified successfully!")
else:
    print("Mailgun signature or timestamp verification FAILED!")

The Gotcha: Forgetting to check the timestamp opens a window for replay attacks. Also, ensure your server's clock is synchronized, preferably with NTP, to avoid false negatives due to clock skew. Mailgun timestamps are in UTC.

How to avoid it: Always include a timestamp freshness