Verify signatures
Check that a webhook came from BetterFans Link before you act on it, with code for Node, Bun, Python and Go.
Every delivery is signed with the endpoint's secret. Check the signature before you trust the event, and reject the request if it does not match. The scheme follows Standard Webhooks, so a Standard Webhooks library works too.
How signing works
- Read the
webhook-id,webhook-timestampandwebhook-signatureheaders. - Join the id, the timestamp and the raw request body with dots:
{webhook-id}.{webhook-timestamp}.{body}. - Take the part of the secret after
whsec_and base64 decode it. The decoded bytes are the key. - Compute HMAC-SHA256 of the joined string with that key, and base64 encode the result.
webhook-signatureis a space-separated list ofv1,<signature>entries. Accept the request when anyv1entry matches your result. Compare in constant time.- Reject the request when the timestamp is more than 5 minutes away from your clock. This stops someone replaying an old request they captured.
BetterFans Link sends one entry per request. The code below still reads the whole list, as the format allows several.
Always sign over the body exactly as it arrived. Parsing the JSON and serializing it again changes the bytes, and the signature no longer matches.
Node
// verify.ts (Node)
import { createHmac, timingSafeEqual } from "node:crypto";
import type { IncomingHttpHeaders } from "node:http";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyWebhook(secret: string, headers: IncomingHttpHeaders, body: Buffer): boolean {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (typeof id !== "string" || typeof timestamp !== "string" || typeof signatures !== "string") {
return false;
}
const sentAt = Number(timestamp);
if (!Number.isInteger(sentAt) || Math.abs(Date.now() / 1000 - sentAt) > TOLERANCE_SECONDS) {
return false;
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.`).update(body).digest();
return signatures.split(" ").some((entry) => {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) return false;
const given = Buffer.from(signature, "base64");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}With Express, give the webhook route a raw body parser so req.body is the unparsed Buffer.
import express from "express";
import { verifyWebhook } from "./verify";
const app = express();
app.post("/webhooks/betterfans-link", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyWebhook(process.env.BFL_WEBHOOK_SECRET!, req.headers, req.body)) {
return res.status(400).send("bad signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// Store the event, then answer.
res.send("ok");
});
app.listen(3000);Bun
This is the verifyWebhook the receiver on Set up webhooks imports. Pass the body from await req.text().
// verify.ts (Bun)
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyWebhook(secret: string, headers: Headers, body: string): boolean {
const id = headers.get("webhook-id");
const timestamp = headers.get("webhook-timestamp");
const signatures = headers.get("webhook-signature");
if (!id || !timestamp || !signatures) return false;
const sentAt = Number(timestamp);
if (!Number.isInteger(sentAt) || Math.abs(Date.now() / 1000 - sentAt) > TOLERANCE_SECONDS) {
return false;
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();
return signatures.split(" ").some((entry) => {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) return false;
const given = Buffer.from(signature, "base64");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}Python
Uses only the standard library. It needs Python 3.9 or later.
# verify.py
import base64
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 5 * 60
def verify_webhook(secret: str, headers, body: bytes) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not msg_id or not timestamp or not signatures:
return False
try:
sent_at = int(timestamp)
except ValueError:
return False
if abs(time.time() - sent_at) > TOLERANCE_SECONDS:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return FalseWith Flask, request.get_data() returns the raw body. With FastAPI, use await request.body(). Both give header objects that ignore case.
import os
from flask import Flask, request
from verify import verify_webhook
app = Flask(__name__)
@app.post("/webhooks/betterfans-link")
def betterfans_link_webhook():
if not verify_webhook(os.environ["BFL_WEBHOOK_SECRET"], request.headers, request.get_data()):
return "bad signature", 400
event = request.get_json()
# Store the event, then answer.
return "ok"Go
Uses only the standard library.
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"net/http"
"strconv"
"strings"
"time"
)
const tolerance = 5 * time.Minute
// VerifyWebhook reports whether body was signed with secret. Pass the raw
// request body, exactly as it arrived.
func VerifyWebhook(secret string, header http.Header, body []byte) bool {
id := header.Get("webhook-id")
timestamp := header.Get("webhook-timestamp")
signatures := header.Get("webhook-signature")
if id == "" || timestamp == "" || signatures == "" {
return false
}
sentAt, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if age := time.Since(time.Unix(sentAt, 0)); age > tolerance || age < -tolerance {
return false
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + timestamp + "."))
mac.Write(body)
expected := mac.Sum(nil)
for _, entry := range strings.Fields(signatures) {
version, signature, ok := strings.Cut(entry, ",")
if !ok || version != "v1" {
continue
}
given, err := base64.StdEncoding.DecodeString(signature)
if err == nil && hmac.Equal(given, expected) {
return true
}
}
return false
}Read the whole body before you verify it.
http.HandleFunc("POST /webhooks/betterfans-link", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
if !webhooks.VerifyWebhook(os.Getenv("BFL_WEBHOOK_SECRET"), r.Header, body) {
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
// Decode body, store the event, then answer.
w.WriteHeader(http.StatusOK)
})Test vector
Use these values to check the HMAC step of your code. The timestamp is in the past, so turn off the 5 minute check while you test with them.
| Input | Value |
|---|---|
| Secret | whsec_2+64X3aPquktmUSUPylh0QjTXj5JYKJy |
webhook-id | evt_3Jd8KqLx0PzR5TnW7vYb2MsC |
webhook-timestamp | 1790000000 |
| Body | {"id":"evt_3Jd8KqLx0PzR5TnW7vYb2MsC","type":"message.received"} |
Expected webhook-signature | v1,KVEjYNkmAttYMeEu6YbqKlbOAgI9j3x4NpT/x5E1maA= |
This secret is an example made for this page. It belongs to no endpoint.
Sign a request yourself
Test events are sent from BetterFans Link's servers, so they cannot reach a server running on your own machine. To test one locally, sign a request in the shell and send it with curl. This works on macOS and Linux.
secret="$BFL_WEBHOOK_SECRET"
id="evt_localtest0000000000001"
timestamp=$(date +%s)
body='{"id":"evt_localtest0000000000001","type":"message.received","data":{"test":true}}'
key=$(printf '%s' "${secret#whsec_}" | base64 -d | od -An -tx1 | tr -d ' \n')
signature=$(printf '%s.%s.%s' "$id" "$timestamp" "$body" \
| openssl dgst -sha256 -mac HMAC -macopt "hexkey:$key" -binary | base64)
curl -X POST http://localhost:3000/webhooks/betterfans-link \
-H "content-type: application/json" \
-H "webhook-id: $id" \
-H "webhook-timestamp: $timestamp" \
-H "webhook-signature: v1,$signature" \
--data "$body"Your server should answer 200. Change one character of body after signing and it should answer 400.
When verification fails
| Cause | Fix |
|---|---|
| The body was parsed before you verified it. | Verify the raw bytes. In Express, use express.raw on the webhook route instead of express.json. |
| The secret is used as a plain string. | Base64 decode the part after whsec_ and use the bytes as the HMAC key. |
| The secret belongs to another endpoint. | Each endpoint has its own secret, and test mode endpoints are separate from live ones. Copy the secret from the endpoint that sends the request. |
| The secret was rotated. | The old secret stops working at once. Put the new one on your server. |
| Your server clock is off. | Keep it synced with NTP. Requests more than 5 minutes off are rejected. |
When you reject a request, answer with a 4xx status. The delivery counts as failed and is retried, so it arrives again once your server is fixed.