Anyone who finds out your webhook's URL can post to it. To be sure a delivery came from DNS Check and wasn't changed along the way, check its signature before acting on it. Deliveries are signed following the Standard Webhooks specification, in every payload format.
The signature covers the request body and two headers: webhook-id (the event ID) and webhook-timestamp (when the attempt was sent). The signature itself arrives in a third header, webhook-signature.
We show your webhook's signing secret, which starts with whsec_, when you edit the webhook on the Webhooks tab. Keep it out of your source code, for example, in an environment variable.
Verify with a Standard Webhooks Library
Standard Webhooks publishes verification libraries for many languages. Pass the library the signing secret exactly as we show it, the request's headers, and the raw request body. Use the body exactly as it arrived: parsing the JSON and serializing it again changes the bytes, and the signature no longer matches.
With Flask and the standardwebhooks package:
import os
from flask import Flask, request
from standardwebhooks import Webhook, WebhookVerificationError
app = Flask(__name__)
webhook = Webhook(os.environ["DNSCHECK_SIGNING_SECRET"])
@app.post("/dnscheck/webhook")
def dnscheck_webhook():
try:
event = webhook.verify(request.get_data(), request.headers)
# The library raises ValueError for some malformed signatures
except (WebhookVerificationError, ValueError):
return "Invalid signature", 400
print(event["type"], event["text"])
return "", 204
Node.js, with Express and the standardwebhooks package:
import express from "express";
import { Webhook } from "standardwebhooks";
const app = express();
const webhook = new Webhook(process.env.DNSCHECK_SIGNING_SECRET);
// The signature covers the body exactly as sent, so read it raw
app.post("/dnscheck/webhook", express.raw({ type: "*/*" }), (req, res) => {
let event;
try {
event = webhook.verify(req.body, req.headers);
} catch (err) {
return res.status(400).send("Invalid signature");
}
console.log(event.type, event.text);
res.sendStatus(204);
});
app.listen(3000);
With net/http (Go 1.22 or later) and the standardwebhooks module, which you install with go get github.com/standard-webhooks/standard-webhooks/libraries/go:
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
)
func main() {
webhook, err := standardwebhooks.NewWebhook(os.Getenv("DNSCHECK_SIGNING_SECRET"))
if err != nil {
log.Fatal(err)
}
http.HandleFunc("POST /dnscheck/webhook", func(w http.ResponseWriter, r *http.Request) {
// The signature covers the body exactly as sent, so read it raw
body, err := io.ReadAll(r.Body)
if err != nil || webhook.Verify(body, r.Header) != nil {
http.Error(w, "Invalid signature", http.StatusBadRequest)
return
}
var event struct {
Type string `json:"type"`
Text string `json:"text"`
}
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
fmt.Println(event.Type, event.Text)
w.WriteHeader(http.StatusNoContent)
})
log.Fatal(http.ListenAndServe(":3000", nil))
}
The Python and Node.js libraries parse the body as JSON after verifying it, while Go's Verify only checks the signature, so the example parses the body itself. For the Plain text format, turn that off with webhook.verify(req.body, req.headers, { jsonParse: false }) in Node.js or webhook.verify(request.get_data(), request.headers, json_parse=False) in Python, then read the message from the raw body. In Go, read the message from the raw body.
Verify without a Library
To verify a signature yourself:
- Build the signed content by joining the
webhook-idheader, thewebhook-timestampheader, and the raw request body with periods:<webhook-id>.<webhook-timestamp>.<body>. - Build the key: remove the
whsec_prefix from the signing secret, and base64-decode the rest. Using the whole secret as the key fails every signature. - Compute the HMAC-SHA256 of the signed content with that key, base64-encode it, and put
v1,in front. - Compare the result to each space-separated value in the
webhook-signatureheader, using a constant-time comparison. The delivery is valid if any of them match. - Reject the delivery if
webhook-timestampis more than five minutes from your server's current time, so a captured request can't be replayed later. Every retry is signed with a fresh timestamp, so retries still pass this check.
Using only the standard library:
import base64
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 5 * 60
def valid_signature(secret, headers, body):
"""headers: the request's headers; body: the raw request body, as bytes."""
event_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not (event_id and timestamp and 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_content = f"{event_id}.{timestamp}.".encode() + body
digest = hmac.new(key, signed_content, hashlib.sha256).digest()
expected = b"v1," + base64.b64encode(digest)
# Compared as bytes, since compare_digest raises on non-ASCII strings
return any(
hmac.compare_digest(signature.encode(), expected)
for signature in signatures.split(" ")
)
Using only Node.js's built-in crypto module:
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
// headers: the request's headers; body: the raw request body, as a Buffer
function validSignature(secret, headers, body) {
const eventId = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!eventId || !timestamp || !signatures) return false;
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
return false;
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const signedContent = Buffer.concat([Buffer.from(`${eventId}.${timestamp}.`), body]);
const digest = createHmac("sha256", key).update(signedContent).digest("base64");
const expected = Buffer.from(`v1,${digest}`);
return signatures.split(" ").some((signature) => {
const given = Buffer.from(signature);
// timingSafeEqual throws on buffers of different lengths
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
Using only the standard library:
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
const toleranceSeconds = 5 * 60
// headers: the request's headers; body: the raw request body
func validSignature(secret string, headers http.Header, body []byte) bool {
eventID := headers.Get("webhook-id")
timestamp := headers.Get("webhook-timestamp")
signatures := headers.Get("webhook-signature")
if eventID == "" || timestamp == "" || signatures == "" {
return false
}
sentAt, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if age := time.Now().Unix() - sentAt; age > toleranceSeconds || age < -toleranceSeconds {
return false
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return false
}
mac := hmac.New(sha256.New, key)
fmt.Fprintf(mac, "%s.%s.", eventID, timestamp)
mac.Write(body)
expected := []byte("v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)))
for _, signature := range strings.Split(signatures, " ") {
if hmac.Equal([]byte(signature), expected) {
return true
}
}
return false
}
Header names can arrive in any capitalization, so pass headers as an object that looks them up case-insensitively, or lowercase the names first. Go's http.Header.Get already ignores capitalization.
Roll the Signing Secret
To replace a webhook's signing secret, click the webhook's drop-down menu on the Webhooks tab, select Roll secret, and confirm. We show you the new secret and keep signing with the old one for 24 hours. Until then, each delivery's webhook-signature header carries two signatures, one made with each secret. Standard Webhooks libraries, and the example above, accept a delivery when any signature matches, so your endpoint keeps verifying deliveries while you update it. Update your endpoint with the new secret before the old one expires; editing the webhook shows exactly when that is.
If a secret is exposed, roll it and update your endpoint right away. Your endpoint decides which secrets it trusts, so once it has only the new secret, a request signed with just the old one fails verification, even while we're still adding an old-secret signature to our deliveries.
Protect your DNS infrastructure with automated monitoring
Get notified immediately when DNS records change. Start monitoring your critical DNS infrastructure for free in under 5 minutes.
No credit card required • Cancel anytime