Early bird discounts live! Claim your offer

Signature Verification

HMAC-SHA256 verification of the MP-Signature header. Stripe-compatible scheme with dual-sign rotation support.

Every delivery carries an MP-Signature: t=<unix>,v1=<hex> header. Consumers must verify it before acting on the payload, or an attacker who guesses the endpoint URL can forge events.

Scheme

MP-Signature: t=<unix_ts>,v1=<hex(hmac_sha256(secret, "<t>.<raw_body>"))>

During secret rotation the header may carry both the old (v0=) and new (v1=) signatures:

MP-Signature: t=<unix>,v0=<hex-with-old-secret>,v1=<hex-with-new-secret>

Accept a request if either signature matches, within a 5-minute timestamp tolerance.

Reference implementations

Node (Express)

secrets accepts a string or an array — pass both old and new during the 24-hour rotation window so a single deploy rides through the cutover.

import crypto from "node:crypto";

export function verifySignature(
	header: string,
	rawBody: string,
	secrets: string | string[],
	toleranceSeconds = 300,
): boolean {
	const parts = Object.fromEntries(
		header.split(",").map((kv) => kv.split("=").map((s) => s.trim())),
	);
	if (!parts.t) return false;

	const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
	if (skew > toleranceSeconds) return false;

	const signed = `${parts.t}.${rawBody}`;
	const secretList = Array.isArray(secrets) ? secrets : [secrets];
	const candidates = [parts.v1, parts.v0].filter(Boolean) as string[];

	for (const secret of secretList) {
		const expected = crypto
			.createHmac("sha256", secret)
			.update(signed)
			.digest("hex");
		const expectedBuf = Buffer.from(expected, "hex");

		for (const candidate of candidates) {
			if (candidate.length !== expected.length) continue;
			const candidateBuf = Buffer.from(candidate, "hex");
			if (crypto.timingSafeEqual(candidateBuf, expectedBuf)) {
				return true;
			}
		}
	}
	return false;
}

PHP (Laravel)

Wire it as a route middleware so every webhook controller stays free of verification noise. Configure the secret(s) under services.memberpass.webhook_secrets — pass an array containing both old and new during a rotation.

config/services.php
'memberpass' => [
    'webhook_secrets' => array_filter([
        env('MEMBERPASS_WEBHOOK_SECRET'),
        env('MEMBERPASS_WEBHOOK_SECRET_PREVIOUS'),
    ]),
],
app/Http/Middleware/VerifySignature.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;

class VerifySignature
{
    public function handle(Request $request, Closure $next, int $toleranceSeconds = 300): Response
    {
        $parts = Str::of($request->header('MP-Signature', ''))
            ->explode(',')
            ->mapWithKeys(function (string $kv): array {
                [$k, $v] = array_pad(explode('=', trim($kv), 2), 2, '');

                return [trim($k) => trim($v)];
            });

        abort_if(blank($parts->get('t')), 400, 'missing timestamp');
        abort_if(
            abs(Carbon::now()->getTimestamp() - (int) $parts->get('t')) > $toleranceSeconds,
            400,
            'timestamp outside tolerance',
        );

        $signed = $parts->get('t').'.'.$request->getContent();
        $candidates = collect([$parts->get('v1'), $parts->get('v0')])->filter();
        $secrets = collect(Config::get('services.memberpass.webhook_secrets', []))->filter();

        $valid = $secrets->contains(
            fn (string $secret): bool => $candidates->contains(
                fn (string $candidate): bool => hash_equals(hash_hmac('sha256', $signed, $secret), $candidate),
            ),
        );

        abort_unless($valid, 400, 'bad signature');

        return $next($request);
    }
}
routes/web.php
Route::post('/webhooks/memberpass', WebhookController::class)
    ->middleware(VerifySignature::class);

Python (Flask)

import hashlib
import hmac
import time
from collections.abc import Iterable


def verifySignature(
    header: str,
    raw_body: bytes,
    secrets: str | Iterable[str],
    tolerance: int = 300,
) -> bool:
    parts: dict[str, str] = {}
    for kv in header.split(","):
        k, _, v = kv.partition("=")
        parts[k.strip()] = v.strip()

    if "t" not in parts:
        return False
    if abs(int(time.time()) - int(parts["t"])) > tolerance:
        return False

    signed = f"{parts['t']}.{raw_body.decode()}".encode()
    secret_list = [secrets] if isinstance(secrets, str) else list(secrets)
    candidates = [c for c in (parts.get("v1"), parts.get("v0")) if c]

    for secret in secret_list:
        expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
        for candidate in candidates:
            if hmac.compare_digest(candidate, expected):
                return True
    return False

Common mistakes

  • Parsing the body as JSON first. Canonicalisation differs between JSON libraries; compute HMAC against the raw body bytes.
  • Comparing with ==. Timing-safe comparison is required. Use crypto.timingSafeEqual, hash_equals, or hmac.compare_digest.
  • Ignoring t. Without the timestamp check, a captured payload can be replayed forever.

Rotating secrets

  • POST /v1/webhook-endpoints/{id}/rotate-secret emits a fresh secret and dual-signs deliveries for 24 hours.
  • During rotation, the header carries both v0= (old secret) and v1= (new secret); consumers accept either.
  • All three reference implementations above accept an array of secrets so a consumer can hold the old and new values simultaneously across the 24-hour window without coordinating a deploy with the rotation.
  • After 24 hours, only the new secret is used; drop the old value from your consumer config before the window closes.

Do not store the secret alongside the webhook URL in version control. Treat it with the same care as an API token. If rotated, the old secret is unrecoverable.

How is this guide?

On this page

MemberPass is a product designed by you — for you.

No boardroom full of executives deciding what we ships next. Our roadmap always shaped by you with your feedback.

Share feedback or a request