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.
'memberpass' => [
'webhook_secrets' => array_filter([
env('MEMBERPASS_WEBHOOK_SECRET'),
env('MEMBERPASS_WEBHOOK_SECRET_PREVIOUS'),
]),
],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);
}
}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 FalseCommon 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. Usecrypto.timingSafeEqual,hash_equals, orhmac.compare_digest. - Ignoring
t. Without the timestamp check, a captured payload can be replayed forever.
Rotating secrets
POST /v1/webhook-endpoints/{id}/rotate-secretemits a fresh secret and dual-signs deliveries for 24 hours.- During rotation, the header carries both
v0=(old secret) andv1=(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?