Signature Verification
HMAC-SHA256 verification of every delivery's SB-Signature header, in a Stripe-compatible scheme, and how rotation replaces the secret.
Every delivery carries an SB-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
SB-Signature: t=<unix_ts>,v1=<hex(hmac_sha256(secret, "<t>.<raw_body>"))>There is exactly one signature per delivery, always v1=, and it is always made with the endpoint's current secret. Accept the request if that signature matches, within a 5-minute timestamp tolerance.
The implementations below also accept a v0= value and a list of secrets.
Subscriby never sends v0= — that tolerance exists so you can hold two
secrets across a rotation, which is
necessary because rotation is instant and has no grace window.
Web Bot Auth signature
Every delivery also carries a second, optional signature that needs no shared secret: a Web Bot Auth signature in the RFC 9421 HTTP message signature format, made with an Ed25519 key whose public half Subscriby publishes.
Signature-Agent: "https://www.subscriby.net"
Signature-Input: sig1=("@authority" "signature-agent");created=1735689600;expires=1735689900;keyid="<thumbprint>";alg="ed25519";nonce="<random>";tag="web-bot-auth"
Signature: sig1=:<base64 signature>:Signature-Agentnames the origin that publishes the key directory:https://www.subscriby.net/.well-known/http-message-signatures-directory, a JWK set served asapplication/http-message-signatures-directory+jsonand itself signed for the host it was requested at. Each key'skidis its RFC 8037 thumbprint, which is thekeyidinSignature-Input.- The signature covers the request's authority (your endpoint's host) and the
Signature-Agentheader, with a five-minute validity window, so a captured delivery cannot be replayed against another host or later. - An endpoint behind Cloudflare can have Cloudflare verify it: their Web Bot Auth verification accepts every Ed25519 key found in a sender's directory.
It is in addition to SB-Signature, not instead of it. The HMAC proves the delivery was made for your endpoint with your secret and covers the body; the Web Bot Auth signature proves the sender is Subscriby. Verify the HMAC always; verify the second signature when you want to know who knocked before reading the body.
Reference implementations
Node (Express)
secrets accepts a string or an array — hold both the current and next secret across a rotation so a single deploy rides through the instant 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.subscriby.webhook_secrets — pass an array containing both old and new during a rotation.
'subscriby' => [
'webhook_secrets' => array_filter([
env('SUBSCRIBY_WEBHOOK_SECRET'),
env('SUBSCRIBY_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('SB-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.subscriby.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/subscriby', 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-secretreturns a fresh secret and replaces the old one immediately. There is no dual-signing and no grace window.- Every delivery carries exactly one
v1=signature. The header never contains av0=value. - Because the cutover is instant, the safe order is: deploy a handler that accepts both the current and the next secret, then rotate, then drop the old value. The reference implementations above take an array of secrets for exactly this reason — the array is how you create the overlap, since the platform does not.
- Deliveries signed with the new secret while your handler still only knows the old one fail verification and are retried on the normal backoff schedule.
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?
Last updated on