Webhook Signature Verification in Laravel

SendPromptly Team
8 min read

If you’ve ever implemented webhook signature verification and thought “this should work”… welcome to the club.

Most guides show the happy path:

  1. compute HMAC
  2. compare signatures
  3. return 200

But in production, signature verification fails for painfully small reasons: JSON parsing, newline differences, proxy behavior, compressed bodies, or reading the body at the wrong time.

This post shows a Laravel-precise implementation that is safe-by-default, and it walks through 12 ways signature verification fails—so you can debug by pattern, not panic.


The rule that fixes 70% of signature mismatches

Verify the signature against the raw request body.
Not the parsed JSON. Not $request->all(). Not a reconstructed string.

In Laravel, you want:

1
$rawBody = $request->getContent(); // raw bytes as a string

Everything else is derived—and derivation is where subtle differences creep in.


A solid “done right” implementation (Laravel middleware)

This example assumes a common signed-webhook pattern:

  • X-Webhook-Timestamp: Unix timestamp (seconds)
  • X-Webhook-Signature: HMAC of {timestamp}.{rawBody} using a shared secret
  • Optional: multiple signatures (for secret rotation)

Providers differ on header names and how the “signed payload” is built. Keep the structure, change the details.

1) Middleware: verify signature, timestamp tolerance, replay protection

Create app/Http/Middleware/VerifyWebhookSignature.php:

  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
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;

class VerifyWebhookSignature
{
    public function handle(Request $request, Closure $next): Response
    {
        $timestamp = $request->header('X-Webhook-Timestamp');
        $signatureHeader = $request->header('X-Webhook-Signature');

        if (!$timestamp || !$signatureHeader) {
            return response('Missing signature headers', 400);
        }

        // 1) Timestamp tolerance (replay window)
        $toleranceSeconds = 300; // 5 minutes
        if (!$this->withinTolerance($timestamp, $toleranceSeconds)) {
            return response('Stale webhook', 400);
        }

        // 2) Raw body (must be the exact bytes received)
        $rawBody = $request->getContent();

        // 3) Build signed payload (adjust to match your provider)
        $signedPayload = $timestamp . '.' . $rawBody;

        // 4) Support secret rotation (primary + previous)
        $secrets = array_filter([
            config('services.webhooks.secret_primary'),
            config('services.webhooks.secret_previous'),
        ]);

        // 5) Some providers send multiple signatures, e.g., "v1=...,v1=..."
        $provided = $this->parseSignatures($signatureHeader);

        $isValid = false;
        foreach ($secrets as $secret) {
            $expected = hash_hmac('sha256', $signedPayload, $secret);
            foreach ($provided as $sig) {
                if (hash_equals($expected, $sig)) { // constant-time compare
                    $isValid = true;
                    break 2;
                }
            }
        }

        if (!$isValid) {
            // Avoid logging secrets. Log safe diagnostics only.
            logger()->warning('Webhook signature mismatch', [
                'ts' => $timestamp,
                'sig_prefix' => substr($signatureHeader, 0, 16),
                'body_sha256' => hash('sha256', $rawBody),
                'content_type' => $request->header('Content-Type'),
                'content_encoding' => $request->header('Content-Encoding'),
            ]);

            return response('Invalid signature', 401);
        }

        // 6) Replay protection: block duplicates within tolerance window
        // Use a stable key: signature + timestamp + body hash
        $replayKey = 'webhook:replay:' . hash('sha256', $timestamp . '|' . $signatureHeader . '|' . $rawBody);
        if (Cache::has($replayKey)) {
            return response('Replay detected', 409);
        }
        Cache::put($replayKey, true, now()->addSeconds($toleranceSeconds));

        return $next($request);
    }

    private function withinTolerance(string $timestamp, int $toleranceSeconds): bool
    {
        if (!ctype_digit($timestamp)) return false;

        $ts = (int) $timestamp;
        $now = time();

        return abs($now - $ts) <= $toleranceSeconds;
    }

    private function parseSignatures(string $header): array
    {
        // Accept either raw hex "abcd..." or "v1=abcd...,v1=efgh..."
        $parts = array_map('trim', explode(',', $header));
        $sigs = [];

        foreach ($parts as $p) {
            if (str_contains($p, '=')) {
                [, $val] = explode('=', $p, 2);
                $sigs[] = trim($val);
            } else {
                $sigs[] = $p;
            }
        }

        return array_values(array_filter($sigs));
    }
}

Register the middleware and apply it only to webhook routes:

1
2
3
// app/Http/Kernel.php (Laravel <=10) or bootstrap/app.php (Laravel 11/12 style)
// ... register middleware alias:
'verify.webhook' => \App\Http\Middleware\VerifyWebhookSignature::class,
1
2
3
// routes/api.php
Route::post('/webhooks/provider', [WebhookController::class, 'handle'])
    ->middleware('verify.webhook');

2) Controller: acknowledge fast, queue the work

1
2
3
4
5
6
7
8
public function handle(Request $request)
{
    // At this point, signature is verified.
    // Keep the handler fast: validate schema, enqueue, return 2xx.
    dispatch(new ProcessWebhookJob($request->all()));

    return response()->json(['ok' => true]);
}

12 ways signature verification fails (and how to recognize each)

Use this list like a decision tree. When you see a mismatch, match the symptom to a cause.

1) You signed the parsed JSON, not the raw body

Symptom: works in local tests, fails for real traffic.
Fix: always sign $request->getContent().

2) JSON re-encoding changed whitespace or key order

If you do json_decode() then json_encode(), you will not get the exact same bytes. Fix: never rebuild the payload you sign.

3) Newline normalization (\n vs \r\n)

Some clients/tools normalize newlines. Some proxies do too. Fix: verify against the raw bytes you received; avoid copying payloads between tools that mutate newlines.

4) Character encoding differences (UTF‑8, emojis, smart quotes)

Payloads with non-ASCII characters can break if you treat them as “text you can safely transform.” Fix: keep payload as bytes; avoid string transformations before verification.

5) You read the body twice (and the second read is empty)

In PHP frameworks, the request body is a stream; some patterns consume it. Symptom: signature mismatch + logged body hash changes between layers (or becomes empty). Fix: only read from $request->getContent() once during verification and pass parsed data forward.

6) You used $request->all() (or $request->input()) as the signed content

Those methods produce structured data, not the original payload. Fix: raw body only.

7) Hex vs Base64 mismatch

Some providers send signatures as hex, others as Base64, others prefix versions. Symptom: your expected signature “looks right” but never matches. Fix: match the encoding exactly. For Base64 signatures, generate bytes HMAC (hash_hmac(..., true)) then base64_encode().

8) Wrong “string to sign” (missing timestamp, wrong delimiter, different canonicalization)

Many providers sign {timestamp}.{payload} or payload alone. Fix: replicate the provider’s documented string-to-sign exactly, including punctuation.

9) Timestamp drift or timezone confusion

You can validate timestamp tolerance with Unix time (seconds). Timezones don’t apply to Unix time—but server clock drift does. Fix: ensure your servers use NTP and keep tolerance reasonable (e.g., 5 minutes).

10) Reverse proxies changed headers (or your app reads the wrong header)

Sometimes CDNs/edge proxies strip or rename headers. Some server configs require explicit “pass-through” for custom headers. Symptom: signature header missing in production but present locally. Fix: verify your proxy/web server forwards X-* headers intact.

11) Content-Encoding / gzip behavior differs between environments

Some clients send compressed bodies. Some proxies decompress (or don’t). Symptom: mismatch only behind certain proxies or only for large payloads. Fix: verify what your app actually receives. Log Content-Encoding and a body hash. If your framework/server presents you the decompressed body, compute signatures on that same representation—or disable compression for webhook endpoints.

12) Secret rotation wasn’t handled

Providers rotate signing secrets. If you only store one secret, you’ll break on rotation day. Fix: support multiple secrets (current + previous) for a rotation window, as shown in the middleware.


Debugging checklist (fast triage)

When a signature mismatch happens, capture these without leaking secrets:

  • timestamp header value
  • first 16 chars of signature header (prefix only)
  • Content-Type and Content-Encoding
  • sha256(rawBody) and strlen(rawBody)
  • your computed expected signature prefix (first 16 chars) in a secure environment

Then answer:

  1. Is the raw body empty? (body stream issue)
  2. Do hashes differ between your logs and what the provider says they sent? (proxy/encoding)
  3. Are you using the correct “string to sign”? (timestamp/delimiter)
  4. Are you matching encoding (hex vs base64) and secret?

Minimal “compute expected signature” helper

1
2
3
4
5
function expectedSignature(string $rawBody, string $timestamp, string $secret): string
{
    $signedPayload = $timestamp . '.' . $rawBody;
    return hash_hmac('sha256', $signedPayload, $secret); // hex
}

Testing locally without fooling yourself

1) Use curl and sign exactly what you send

In a terminal (where you can control bytes), you can sign a payload file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
payload='{"event":"ping","id":"123","note":"hello"}'
ts=$(date +%s)
secret='your-secret'

sig=$(php -r 'echo hash_hmac("sha256", $argv[1].".".$argv[2], $argv[3]);' "$ts" "$payload" "$secret")

curl -i -X POST "http://localhost:8000/api/webhooks/provider" \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Timestamp: $ts" \
  -H "X-Webhook-Signature: $sig" \
  --data "$payload"

2) Don’t copy/paste JSON between tools

Postman, browser consoles, and some “API testing” tools can mutate whitespace/newlines. That’s why “it works in Postman” is not a proof.

If you must use Postman:

  • sign the payload after it’s finalized
  • ensure it sends the exact body you sign (harder than it sounds)

What this looks like in SendPromptly integrations

SendPromptly’s philosophy is: always sign webhooks, always include a timestamp, and always make verification deterministic.

If you’re verifying SendPromptly webhooks:

  • map header names (signature + timestamp)
  • replicate the documented “string to sign”
  • implement tolerance + replay protection

If your verification is failing, the 12 failure modes above usually point to the issue within minutes.


Quick reference: secure defaults

  • Verify raw body
  • Use hash_equals
  • Add timestamp tolerance (e.g., 5 minutes)
  • Add replay protection
  • Keep webhook handlers fast (queue work)
  • Support secret rotation

If you do those six things, you’ll be ahead of most production integrations.