Verifying webhook signatures
LakeSail signs every webhook delivery with HMAC-SHA256 so your endpoint can confirm the request is genuine. This page is the implementation reference: header format, the algorithm, and verified code samples in three languages.
For the broader notification model (channels, rules, event types), see Notifications.
When signatures are sent
Signatures are sent when a webhook channel is configured with a signing secret. Without a secret, deliveries are unauthenticated (don't expose your endpoint to the public internet without a secret).
You set the secret when creating or editing the channel; rotate it periodically by editing the channel and pasting a new value.
The signature header
Every signed delivery includes:
LakeSail-Signature: sha256=<hex-encoded HMAC digest>The digest is computed over the raw request body, with your channel's signing secret as the HMAC key.
Compute over the raw body
Hash the body bytes exactly as received. Don't parse the JSON, don't strip whitespace, don't re-encode. Most signature mismatches are caused by middleware that mutates the body before your handler runs.
Verification algorithm
- Read the raw request body.
- Read the
LakeSail-Signatureheader. Strip thesha256=prefix. - Compute
HMAC-SHA256(body, secret)and hex-encode it. - Compare your computed digest to the header value using a constant-time comparison (e.g.
hmac.compare_digestin Python). Plain==is a timing-attack risk. - If they match, the request is authentic. Otherwise, return
401and don't process the body.
Code samples
Python (Flask)
import hmac
import hashlib
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = b"<your-signing-secret>"
@app.post("/webhook")
def webhook():
sig_header = request.headers.get("LakeSail-Signature", "")
if not sig_header.startswith("sha256="):
abort(401)
received = sig_header.removeprefix("sha256=")
expected = hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(received, expected):
abort(401)
payload = request.get_json()
# process the event...
return "", 200Node.js (Express)
import express from "express";
import crypto from "node:crypto";
const app = express();
const SECRET = "<your-signing-secret>";
// IMPORTANT: capture the raw body before any JSON parser runs.
app.post(
"/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const sigHeader = req.header("LakeSail-Signature") ?? "";
if (!sigHeader.startsWith("sha256=")) return res.sendStatus(401);
const received = sigHeader.slice("sha256=".length);
const expected = crypto
.createHmac("sha256", SECRET)
.update(req.body)
.digest("hex");
const ok =
received.length === expected.length &&
crypto.timingSafeEqual(
Buffer.from(received, "hex"),
Buffer.from(expected, "hex"),
);
if (!ok) return res.sendStatus(401);
const payload = JSON.parse(req.body.toString("utf8"));
// process the event...
res.sendStatus(200);
},
);Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strings"
)
const secret = "<your-signing-secret>"
func webhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
sigHeader := r.Header.Get("LakeSail-Signature")
if !strings.HasPrefix(sigHeader, "sha256=") {
http.Error(w, "missing signature", http.StatusUnauthorized)
return
}
received, err := hex.DecodeString(strings.TrimPrefix(sigHeader, "sha256="))
if err != nil {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := mac.Sum(nil)
if !hmac.Equal(received, expected) {
http.Error(w, "signature mismatch", http.StatusUnauthorized)
return
}
// parse body and process the event...
w.WriteHeader(http.StatusOK)
}Rotating the secret
- Generate a new secret (32 random bytes is the usual minimum).
- Edit the webhook channel in Settings → Notifications → Channels and paste the new value.
- Update your endpoint to accept signatures from either the old or new secret for a brief overlap window.
- After the overlap, drop the old secret from your endpoint.
LakeSail uses the latest secret for new deliveries; there's no dual-signing window built in, so you handle the overlap on your side.
Troubleshooting
- Signatures never match. Almost always a body-mutation issue: a JSON middleware running before your handler, or a reverse proxy reformatting the body. Capture the raw bytes and verify they're identical to what was sent.
- Some signatures match, some don't. Check character encoding — make sure the body is being read as bytes, not as a string with implicit re-encoding.
- Timing varies wildly. Make sure you're using
hmac.compare_digest/crypto.timingSafeEqual/hmac.Equal. Plain==leaks timing. - Slack channels don't sign deliveries. Slack's incoming webhooks are URL-secret based, not HMAC-signed. The Signature header is only present for channel type
webhook.
API reference
- Notifications —
CreateNotificationChannel(providesecret),UpdateNotificationChannel(rotate),TestNotificationChannel(send a signed test payload).