Verify Mailgun Webhook Signatures in Laravel

Verify Mailgun webhook signatures in Laravel

Validate Mailgun webhook authenticity with HMAC-SHA256 and single-use tokens to prevent replay. This guide gives a drop-in middleware example that verifies timestamp, token, and signature, enforces a timestamp window, and caches tokens to block replays.

The primary keyword mailgun webhook signature verification appears in the examples and test steps.

What Mailgun signs

Understand the fields Mailgun provides for verification so you use the correct key and inputs.

timestamp + token + signature fields

Mailgun sends timestamp, token, and signature fields; verification uses timestamp + token as the signed input.

Signing key (where it lives)

Use the webhook signing key shown in the Mailgun dashboard (not the account API key).

Micro checklist:

  • Retrieve the webhook signing key from Mailgun dashboard.
  • Store it in a secure config (env or secrets manager).
  • Do not log the signing key or the raw signature.

Verification algorithm

Follow Mailgun’s algorithm exactly: concatenate timestamp+token (no separator), HMAC-SHA256, and constant-time compare.

Concatenate timestamp + token (no separator)

The input to HMAC is timestamp + token.

HMAC-SHA256 with your webhook signing key

Compute the HMAC using your webhook signing key and hex-encode the result.

Constant-time compare

Use hash_equals() to compare the expected and provided signatures.

Common gotcha: Using the API key instead of the webhook signing key or performing string comparisons with == instead of hash_equals().

Replay protection

Block token reuse and enforce a timestamp window to prevent replay attacks.

Cache token and reject repeats

Store the token in a short TTL cache (e.g., 10 minutes) and reject any repeat token submissions.

Timestamp drift window

Reject requests outside a small window (±5 minutes is typical).

Micro checklist:

  • Cache tokens for single use (10 minutes).
  • Enforce a 5-minute timestamp drift window.
  • Log suspicious activity without storing secrets.

Laravel middleware implementation

Validate signature early and reject replays before queueing.

Validate early, before queueing

Put verification middleware near the top of the middleware stack so downstream code never processes unauthenticated payloads.

Log safely (never log signing key)

Log only safe context (message id, recipient); never include signature or token values in logs.

Required code snippets

A) Middleware

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class VerifyMailgunWebhookSignature
{
    public function handle(Request $request, Closure $next)
    {
        $timestamp = (string) $request->input('timestamp');
        $token     = (string) $request->input('token');
        $signature = (string) $request->input('signature');

        if (!$timestamp || !$token || !$signature) {
            return response()->json(['error' => 'missing signature fields'], 401);
        }

        // 5 min drift window
        if (abs(time() - (int)$timestamp) > 300) {
            return response()->json(['error' => 'stale timestamp'], 401);
        }

        // Replay protection: token should be single-use
        $cacheKey = "mailgun:webhook:token:$token";
        if (Cache::has($cacheKey)) {
            return response()->json(['error' => 'replay detected'], 401);
        }

        $signingKey = config('services.mailgun.webhook_signing_key');

        $expected = hash_hmac('sha256', $timestamp.$token, $signingKey);

        if (!hash_equals($expected, $signature)) {
            return response()->json(['error' => 'signature verification failed'], 401);
        }

        Cache::put($cacheKey, true, now()->addMinutes(10));

        return $next($request);
    }
}

Test steps (curl + expected response)

  1. Invalid signature
1
2
3
4
5
curl -i -X POST "https://YOUR-APP.test/api/webhooks/mailgun/events" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode 'timestamp=1700000000' \
  --data-urlencode 'token=abc123' \
  --data-urlencode 'signature=WRONG'

Expected: 401 signature verification failed

  1. Valid signature (local)
  • Compute signature:
1
php -r '$ts="1700000000"; $tok="abc123"; $key="YOUR_SIGNING_KEY"; echo hash_hmac("sha256",$ts.$tok,$key), PHP_EOL;' 
  • Use that output as signature in curl → Expected: 204 No Content

Mailgun’s official algorithm: concatenate timestamp+token (no separator), HMAC-SHA256 with your webhook signing key, compare hexdigest, and optionally cache tokens to prevent replay.

Common failure modes

  1. Using the wrong key (API key vs webhook signing key).
  2. Not using hash_equals (timing leak).
  3. No token caching → replay attacks possible.
  4. No timestamp drift window → accepts old replays.
  5. Verifying after parsing/mutating fields (verify on original field values).

See verified events in one place: forward only verified webhooks, then inspect delivery runs. Open Sample Project · Open Message Log

Conclusion

  • Verify Mailgun webhook signatures using HMAC-SHA256 over timestamp+token.
  • Cache tokens to prevent replay attacks.
  • Use hash_equals() and validate timestamp drift.
  • Place verification early and never log secrets.