/

Security

Verify signatures

Check that a webhook came from Sluice and was not changed on the way.

4 min read

Anyone can send a POST request to your customer's URL. The signature proves a request came from Sluice and that nobody changed the body. Your customers should reject any request that fails this check, and the client libraries make it one line.

The signature header

Sluice-Signature: t=1757944931,v1
Sluice-Signature: t=1757944931,v1
Sluice-Signature: t=1757944931,v1

t is the Unix time the request was signed. v1 is an HMAC with SHA-256 of the timestamp, a dot and the raw request body, using the endpoint's signing secret. Secrets start with whsec_.

Verify with the library

import { verify } from "@sluice/node";

app.post("/webhooks/sluice", express.raw({ type: "*/*" }), (req, res) => {
  const event = verify(req.body, req.headers, process.env.SLUICE_SECRET);
  // throws if the signature or timestamp is wrong
  res.sendStatus(200);
});
import { verify } from "@sluice/node";

app.post("/webhooks/sluice", express.raw({ type: "*/*" }), (req, res) => {
  const event = verify(req.body, req.headers, process.env.SLUICE_SECRET);
  // throws if the signature or timestamp is wrong
  res.sendStatus(200);
});
import { verify } from "@sluice/node";

app.post("/webhooks/sluice", express.raw({ type: "*/*" }), (req, res) => {
  const event = verify(req.body, req.headers, process.env.SLUICE_SECRET);
  // throws if the signature or timestamp is wrong
  res.sendStatus(200);
});

Verify by hand

  1. Split the header on commas and read t and v1

  2. Reject the request if t is more than 5 minutes from your clock

  3. Compute the HMAC of t + "." + body with the secret

  4. Compare it to v1 with a constant-time comparison

import hmac, hashlib, time

def verify(body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    signed = f"{parts['t']}.".encode() + body
    digest = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(digest, parts["v1"])
import hmac, hashlib, time

def verify(body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    signed = f"{parts['t']}.".encode() + body
    digest = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(digest, parts["v1"])
import hmac, hashlib, time

def verify(body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    signed = f"{parts['t']}.".encode() + body
    digest = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(digest, parts["v1"])

Always verify the raw body. Parsing the JSON and serializing it again changes whitespace and breaks the signature.

Something missing or wrong on this page? Tell us at support@sluicehq.dev and we will fix it.

Create a free website with Framer, the website builder loved by startups, designers and agencies.