Version

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-Agent names the origin that publishes the key directory: https://www.subscriby.net/.well-known/http-message-signatures-directory, a JWK set served as application/http-message-signatures-directory+json and itself signed for the host it was requested at. Each key's kid is its RFC 8037 thumbprint, which is the keyid in Signature-Input.
  • The signature covers the request's authority (your endpoint's host) and the Signature-Agent header, 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.

config/services.php
'subscriby' => [
    'webhook_secrets' => array_filter([
        env('SUBSCRIBY_WEBHOOK_SECRET'),
        env('SUBSCRIBY_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('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);
    }
}
routes/web.php
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 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 returns 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 a v0= 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

Version

On this page

Subscriby 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

For AI agents: llms.txt · llms-full.txt