Medium Security

Verifying Webhook Signatures with HMAC: Raw Body, Timing-Safe Compare and Replay Protection

A webhook URL is a public endpoint that changes your data. Verify HMAC signatures on the raw body, compare in constant time and block replays with timestamps.

01 Nov, 2026 10 min Updated 23 Oct, 2026 7 Code blocks
Diagram
sequenceDiagram participant P as Provider participant M as Middleware participant C as Cache participant H as Controller P->>M: POST raw body and signature header M->>M: Timestamp within 5 minutes M->>M: HMAC of timestamp.body, constant time compare M->>C: Add event id if absent C-->>M: Added or already seen M->>H: Forward verified request H-->>P: 2xx quickly, work goes to a queue

Your app exposes POST /webhooks/billing so the payment provider can tell you an invoice was paid. The handler marks the order as paid and ships the goods. It works perfectly in staging. Then someone finds the URL in a mobile app bundle, sends {"type":"invoice.paid","order_id":1234} with curl, and gets a laptop for free. A webhook endpoint is a public, unauthenticated URL that changes state in your system. Without signature verification, anyone who can guess the payload format can drive it.

Almost every serious provider (Stripe, GitHub, Shopify, Svix-based services, Paddle) solves this the same way: they sign each request with HMAC using a secret only you and they know. Verifying that signature correctly takes about 40 lines, but there are four details that people get wrong over and over: the raw body, timing-safe comparison, the timestamp window, and replay protection.

How an HMAC Webhook Signature Works

HMAC-SHA256 takes a secret key and a message and produces a 32-byte tag. Without the key you cannot produce a valid tag for a modified message, and you cannot recover the key from tags you have seen. The provider computes the tag over the exact bytes it sends and puts it in a header. You recompute it over the exact bytes you received and compare.

A typical scheme looks like this (the header names vary by provider; the structure is nearly identical):

Webhook-Id: evt_01JC8Z4K
Webhook-Signature: t=1761900000,v1=<64 lowercase hex chars>

signed string = "1761900000" + "." + <exact raw request body bytes>
v1            = hex( HMAC-SHA256( secret, signed string ) )

Two things make this scheme stronger than "HMAC the body". The timestamp is part of the signed string, so an attacker cannot take an old captured request and change its timestamp. And the header can carry several v1 values, which lets the provider sign with both the old and new secret during a secret rotation.

The Verification Flow

sequenceDiagram participant P as Provider participant M as Middleware participant C as Cache participant H as Controller P->>M: POST raw body and signature header M->>M: Timestamp within 5 minutes M->>M: HMAC of timestamp.body, constant time compare M->>C: Add event id if absent C-->>M: Added or already seen M->>H: Forward verified request H-->>P: 2xx quickly, work goes to a queue

The order matters. Cheap checks first (timestamp), then the cryptographic check, then the stateful replay check. Only a request that passed all three reaches your controller. Note what is not in the list: parsing JSON. You verify bytes, and only then trust and decode them.

Laravel Middleware Implementation

Put the verification in middleware so every webhook route gets the same guarantees and controllers stay focused on business logic. The provider name is a middleware parameter, so one class serves every integration.

namespace App\Http\Middleware;

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

final class VerifyWebhookSignature
{
    private const TOLERANCE_SECONDS = 300;

    public function handle(Request $request, Closure $next, string $provider): Response
    {
        $secrets = (array) config("services.{$provider}.webhook_secrets", []);
        $header = (string) $request->header('Webhook-Signature', '');
        $parts = $this->parseHeader($header);
        $timestamp = (int) ($parts['t'][0] ?? 0);

        abort_if(abs(now()->getTimestamp() - $timestamp) > self::TOLERANCE_SECONDS, 401, 'Stale timestamp.');

        // getContent() is the raw body. Never rebuild it from $request->all().
        $payload = $request->getContent();

        abort_unless($this->matchesAny($secrets, "{$timestamp}.{$payload}", $parts['v1'] ?? []), 401, 'Bad signature.');

        $key = "webhook:{$provider}:".$request->header('Webhook-Id', hash('sha256', $header));

        if (! Cache::add($key, true, self::TOLERANCE_SECONDS * 2)) {
            return response()->noContent(); // replay or duplicate delivery: ack, do nothing
        }

        $response = $next($request);

        if (! $response->isSuccessful()) {
            Cache::forget($key); // let the provider's retry through
        }

        return $response;
    }

    /** @return array<string, list<string>> */
    private function parseHeader(string $header): array
    {
        $parts = [];

        foreach (explode(',', $header) as $item) {
            [$key, $value] = array_pad(explode('=', trim($item), 2), 2, '');
            $parts[$key][] = $value;
        }

        return $parts;
    }

    /** @param list<string> $signatures */
    private function matchesAny(array $secrets, string $signed, array $signatures): bool
    {
        foreach ($secrets as $secret) {
            $expected = hash_hmac('sha256', $signed, $secret);

            foreach ($signatures as $signature) {
                if (hash_equals($expected, $signature)) {
                    return true;
                }
            }
        }

        return false;
    }
}

A few deliberate choices in there:

  • $request->getContent() returns the body exactly as received. json_encode($request->all()) would not: key order, whitespace, slash escaping and Unicode escaping can all differ, and the signature will fail randomly for some payloads.
  • hash_equals() compares in constant time. A plain === returns as soon as the first byte differs, which in principle leaks how many leading characters an attacker got right. Over a network the signal is noisy, but it is free to avoid, so avoid it.
  • Secrets come from config('services.billing.webhook_secrets'), an array. During rotation, set it to array_filter([env('BILLING_WEBHOOK_SECRET'), env('BILLING_WEBHOOK_SECRET_PREVIOUS')]) and drop the old one when the provider confirms the switch.
  • Replay protection uses Cache::add(), which only writes if the key does not exist and is atomic on Redis, Memcached and the database store. The TTL just needs to outlive the timestamp window: after 5 minutes the timestamp check rejects the request anyway.
  • If the controller fails, the key is removed so the provider's retry is processed instead of silently swallowed.

Register the alias and attach it to the route:

// bootstrap/app.php
use App\Http\Middleware\VerifyWebhookSignature;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->alias(['webhook.signed' => VerifyWebhookSignature::class]);
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        //
    })
    ->create();
// routes/api.php
use App\Http\Controllers\BillingWebhookController;
use Illuminate\Support\Facades\Route;

Route::post('/webhooks/billing', BillingWebhookController::class)
    ->middleware('webhook.signed:billing');

Routes in routes/api.php do not run CSRF verification. If you put webhook routes in web.php, you must exclude them with $middleware->validateCsrfTokens(except: ['webhooks/*']), otherwise every delivery gets a 419.

The Same Check in Node.js and Python

In Express, the classic bug is a global app.use(express.json()) that consumes and parses the body before your route sees it. Use express.raw() on the webhook route so req.body stays a Buffer.

const crypto = require('node:crypto');
const express = require('express');

const app = express();

function safeEqualHex(a, b) {
  const left = Buffer.from(a, 'hex');
  const right = Buffer.from(b, 'hex');
  // timingSafeEqual throws on different lengths, so check first
  return left.length === right.length && crypto.timingSafeEqual(left, right);
}

// express.raw keeps req.body as a Buffer; register it BEFORE any global express.json()
app.post('/webhooks/billing', express.raw({ type: 'application/json' }), (req, res) => {
  const parts = Object.fromEntries(
    (req.get('Webhook-Signature') ?? '').split(',').map((p) => p.trim().split('=', 2)),
  );
  const ts = Number(parts.t);

  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) {
    return res.sendStatus(401);
  }

  const expected = crypto
    .createHmac('sha256', process.env.BILLING_WEBHOOK_SECRET)
    .update(`${ts}.`)
    .update(req.body)
    .digest('hex');

  if (!safeEqualHex(expected, parts.v1 ?? '')) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // enqueue event.id + payload, then answer quickly
  res.sendStatus(200);
});

crypto.timingSafeEqual() throws a RangeError when the buffers have different lengths, which an attacker can trigger with a short signature. Check the length first and treat a mismatch as invalid. In Python, hmac.compare_digest() handles different lengths for you:

import hashlib
import hmac
import time


def verify_webhook(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
    parts: dict[str, list[str]] = {}
    for item in header.split(","):
        key, _, value = item.strip().partition("=")
        parts.setdefault(key, []).append(value)

    raw_ts = parts.get("t", [""])[0]
    if not raw_ts.isdigit() or abs(time.time() - int(raw_ts)) > tolerance:
        return False

    expected = hmac.new(secret.encode(), raw_ts.encode() + b"." + body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, sig) for sig in parts.get("v1", []))

In Django or Flask, use request.body / request.get_data(), which are bytes, never the parsed JSON.

Testing the Middleware

The tests that matter are the negative ones: a valid request is easy to get right by accident, a rejected forgery is what you are actually buying. With Pest you can register a throwaway route in the test and drive it with raw content:

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;

beforeEach(function () {
    config(['services.billing.webhook_secrets' => ['whsec_test']]);

    Route::post('/_test/webhook', function () {
        Cache::increment('handled');

        return response()->noContent();
    })->middleware('webhook.signed:billing');
});

function sendWebhook(string $body, int $timestamp, string $secret = 'whsec_test'): Illuminate\Testing\TestResponse
{
    $signature = hash_hmac('sha256', "{$timestamp}.{$body}", $secret);

    return test()->call('POST', '/_test/webhook', [], [], [], [
        'HTTP_WEBHOOK_SIGNATURE' => "t={$timestamp},v1={$signature}",
        'HTTP_WEBHOOK_ID' => 'evt_1',
        'CONTENT_TYPE' => 'application/json',
    ], $body);
}

it('accepts a correctly signed payload once', function () {
    $body = '{"id":"evt_1","type":"invoice.paid"}';

    sendWebhook($body, now()->getTimestamp())->assertNoContent();
    sendWebhook($body, now()->getTimestamp())->assertNoContent(); // replay

    expect(Cache::get('handled'))->toBe(1);
});

it('rejects a tampered body', function () {
    $signature = hash_hmac('sha256', now()->getTimestamp().'.{"amount":100}', 'whsec_test');

    test()->call('POST', '/_test/webhook', [], [], [], [
        'HTTP_WEBHOOK_SIGNATURE' => 't='.now()->getTimestamp().",v1={$signature}",
    ], '{"amount":1000000}')->assertUnauthorized();
});

it('rejects stale timestamps and wrong secrets', function () {
    sendWebhook('{}', now()->subMinutes(6)->getTimestamp())->assertUnauthorized();
    sendWebhook('{}', now()->getTimestamp(), 'whsec_wrong')->assertUnauthorized();

    expect(Cache::get('handled'))->toBeNull();
});

Laravel's test clock helpers ($this->travel() / travelTo()) work here because the middleware uses now() instead of time(). If you switch to a real Redis cache in tests, flush the replay keys between tests or each test will look like a replay of the previous one.

Common Pitfalls

  • Verifying re-encoded JSON. Always hash the raw bytes. This is the number one cause of "signature works in tests, fails for some production events".
  • Comparing with ==. Besides the timing leak, PHP's loose comparison treats "0e1234..." hex strings as equal numbers. Use hash_equals().
  • No timestamp check. A valid request captured from a log or a proxy can be replayed forever. GitHub-style signatures (X-Hub-Signature-256: sha256=...) do not sign a timestamp, so for those, deduplicate on the delivery id header instead.
  • Too tight a window. Clock skew and provider retries exist. Five minutes is the common default; do not go below one minute.
  • Doing slow work before responding. Providers time out after a few seconds and retry, so you get duplicates. Verify, store or queue the event, return 2xx, and process it in a job.
  • Trusting fields that are not signed. Headers such as Webhook-Id are only safe to use for dedup if the provider includes them in the signed string or you dedup on the event id inside the verified body.
  • Leaking the secret. Do not log full headers, do not commit .env, and keep a separate secret per environment and per tenant so staging leaks cannot forge production events.

When to Use This (and When Not To)

Use HMAC verification for any inbound webhook from a provider that supports it, and for webhooks you send to your own customers: sign outgoing requests the same way so they can verify you.

HMAC is not the right tool when:

  • The sender cannot keep a secret. Browsers and mobile apps cannot hold an HMAC key; anyone can extract it. Authenticate users instead.
  • Many independent receivers must verify the same message. A shared secret means every receiver can also forge messages. Use asymmetric signatures (Ed25519, or the RS256/ES256 used in JWTs) so only the sender holds the private key.
  • You need confidentiality. HMAC proves who sent the bytes and that they were not changed. It does not hide them. The transport still needs TLS.

As a final layer, some providers publish their source IP ranges. An IP allowlist is a nice extra, but it is not a replacement: IP ranges change, and the signature is what actually proves authenticity.

Interactive challenge

Try the challenge

Practice what you just learned. Write your solution, reveal hints if you get stuck.

Instructions

Implement two functions (PHP: signWebhook / verifyWebhook, Python: sign_webhook / verify_webhook, JavaScript: signWebhook / verifyWebhook). sign(secret, timestamp, body) returns the header value 't=<timestamp>,v1=<hex>' where hex is the lowercase hex HMAC-SHA256 of '<timestamp>.<body>' keyed with secret (Python body is bytes, PHP and JS body is a string). verify(secret, header, body, now, tolerance = 300) returns a boolean. Parse the header as comma-separated key=value items, trimming spaces around each item; a key may appear several times (several v1 values). Return false if t is missing or not made only of digits, or if the absolute difference between now and t is greater than tolerance. Return true if ANY v1 value equals the expected signature, comparing in constant time (hash_equals, hmac.compare_digest, crypto.timingSafeEqual with an equal-length check). Otherwise return false. Never throw for malformed headers.

Starter code

php
function signWebhook(string $secret, int $timestamp, string $body): string
{
    // TODO: return "t=<timestamp>,v1=<hex HMAC-SHA256 of "<timestamp>.<body>">"
    throw new LogicException('TODO');
}

function verifyWebhook(string $secret, string $header, string $body, int $now, int $tolerance = 300): bool
{
    // TODO: parse "key=value" pairs separated by commas (v1 may repeat)
    // TODO: reject a missing / non-numeric t, or |now - t| > tolerance
    // TODO: accept if ANY v1 equals the expected signature (use hash_equals)
    throw new LogicException('TODO');
}
python
import hashlib
import hmac


def sign_webhook(secret: str, timestamp: int, body: bytes) -> str:
    # TODO: return "t=<timestamp>,v1=<hex HMAC-SHA256 of b"<timestamp>." + body>"
    raise NotImplementedError


def verify_webhook(secret: str, header: str, body: bytes, now: int, tolerance: int = 300) -> bool:
    # TODO: parse comma-separated key=value pairs (v1 may repeat)
    # TODO: reject a missing / non-numeric t, or abs(now - t) > tolerance
    # TODO: accept if ANY v1 matches, compared with hmac.compare_digest
    raise NotImplementedError
javascript
const crypto = require('node:crypto');

function signWebhook(secret, timestamp, body) {
  // TODO: return `t=${timestamp},v1=<hex HMAC-SHA256 of "<timestamp>.<body>">`
  throw new Error('TODO');
}

function verifyWebhook(secret, header, body, now, tolerance = 300) {
  // TODO: parse comma-separated key=value pairs (v1 may repeat)
  // TODO: reject a missing / non-numeric t, or |now - t| > tolerance
  // TODO: accept if ANY v1 matches; use crypto.timingSafeEqual (equal lengths only!)
  throw new Error('TODO');
}

Your solution

Hints

All hints revealed
Hint 1

Sign the exact string timestamp + '.' + body; any extra byte, even a trailing space, must break verification

Hint 2

Collect values per key into a list so repeated v1 entries (secret rotation) are all tried

Hint 3

crypto.timingSafeEqual throws when lengths differ, so compare lengths first and treat a mismatch as false

Solution · php
function signWebhook(string $secret, int $timestamp, string $body): string
{
    return "t={$timestamp},v1=".hash_hmac('sha256', "{$timestamp}.{$body}", $secret);
}

function verifyWebhook(string $secret, string $header, string $body, int $now, int $tolerance = 300): bool
{
    $parts = [];
    foreach (explode(',', $header) as $item) {
        [$key, $value] = array_pad(explode('=', trim($item), 2), 2, '');
        $parts[$key][] = $value;
    }

    $t = $parts['t'][0] ?? '';
    if (! ctype_digit($t) || abs($now - (int) $t) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', "{$t}.{$body}", $secret);
    foreach ($parts['v1'] ?? [] as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }

    return false;
}
Solution · python
import hashlib
import hmac


def sign_webhook(secret: str, timestamp: int, body: bytes) -> str:
    mac = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256)
    return f"t={timestamp},v1={mac.hexdigest()}"


def verify_webhook(secret: str, header: str, body: bytes, now: int, tolerance: int = 300) -> bool:
    parts: dict[str, list[str]] = {}
    for item in header.split(","):
        key, _, value = item.strip().partition("=")
        parts.setdefault(key, []).append(value)

    t = parts.get("t", [""])[0]
    if not (t.isascii() and t.isdigit()) or abs(now - int(t)) > tolerance:
        return False

    expected = hmac.new(secret.encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, sig) for sig in parts.get("v1", []))
Solution · javascript
const crypto = require('node:crypto');

function signWebhook(secret, timestamp, body) {
  const sig = crypto.createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
  return `t=${timestamp},v1=${sig}`;
}

function verifyWebhook(secret, header, body, now, tolerance = 300) {
  const parts = {};
  for (const item of header.split(',')) {
    const trimmed = item.trim();
    const i = trimmed.indexOf('=');
    const key = i === -1 ? trimmed : trimmed.slice(0, i);
    const value = i === -1 ? '' : trimmed.slice(i + 1);
    (parts[key] ??= []).push(value);
  }

  const t = (parts.t ?? [''])[0];
  if (!/^\d+$/.test(t) || Math.abs(now - Number(t)) > tolerance) {
    return false;
  }

  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex'),
  );
  return (parts.v1 ?? []).some((sig) => {
    const given = Buffer.from(sig);
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

More from Security