Appearance
Verifying Signatures
Every request Krafter makes to your endpoint can be signed: webhook deliveries for every service, forwarded events, and cron jobs. Signing is on whenever the thing making the call has a secret — a webhook's secret, or a cron job's signing_secret.
The scheme is the same everywhere, so you write the verification once.
The header
A signed request carries one header:
x-krafter-signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")>t is the moment we sent it. v1 is the scheme version, so a future change has somewhere to go.
The timestamp is inside the signed payload. That is what makes the signature expire: sign the body alone and the value never changes, so a request captured once stays valid forever.
Verify it
Three steps, in this order:
- Split the header into
tandv1. - Check that
tis recent — we recommend a tolerance of 300 seconds. Reject anything older. - Compute
HMAC-SHA256(secret, "<t>." + rawBody)and compare it tov1in constant time.
Step 2 is the one that does the work. Skipping it leaves you exactly where x-krafter-signature left you, because a signature with no expiry is a signature a replayer can reuse.
A replayer cannot buy time by editing t: it is inside the signed payload, so changing it invalidates v1.
Node.js
js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyKrafterSignature(req, secret, toleranceSeconds = 300) {
const header = req.headers["x-krafter-signature"] ?? "";
const parts = Object.fromEntries(
header.split(",").map((p) => p.trim().split("=", 2)),
);
const t = Number(parts.t);
const provided = parts.v1;
if (!Number.isInteger(t) || !provided) return false;
// 2. Freshness first — a valid signature on a stale request is still a replay.
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.${req.rawBody}`)
.digest("hex");
const a = Buffer.from(provided);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}Elixir
elixir
def verify(header, raw_body, secret, tolerance \\ 300) do
with %{"t" => t_str, "v1" => provided} <-
header |> String.split(",") |> Map.new(&(&1 |> String.trim() |> String.split("=", parts: 2) |> List.to_tuple())),
{t, ""} <- Integer.parse(t_str),
true <- abs(System.system_time(:second) - t) <= tolerance do
expected =
:crypto.mac(:hmac, :sha256, secret, "#{t}.#{raw_body}")
|> Base.encode16(case: :lower)
Plug.Crypto.secure_compare(provided, expected)
else
_ -> false
end
endPython
python
import hashlib, hmac, time
def verify_krafter_signature(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
parts = dict(
p.split("=", 1) for p in (x.strip() for x in header.split(",")) if "=" in p
)
t, provided = parts.get("t"), parts.get("v1")
if not t or not provided or not t.isdigit():
return False
# Freshness first — a valid signature on a stale request is still a replay.
if abs(int(time.time()) - int(t)) > tolerance:
return False
expected = hmac.new(
secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(provided, expected)Use the raw body
rawBody must be the exact bytes Krafter sent. Re-serialising parsed JSON will not match — key order and whitespace change the signature. Capture the raw body before any body-parser middleware runs.
Retries and duplicates
Each delivery attempt is signed afresh with its own t, so a retry is not a replay and passes the freshness check normally. Retries can still mean your endpoint sees the same logical event twice — that is what the per-delivery id header is for. Deduplicate on it:
| Service | Delivery id header |
|---|---|
| Webhooks, and every service's own webhooks | x-krafter-webhook-id |
| Event forwarding | x-krafter-event-id |
| Cron | x-krafter-job-id (identifies the job, not the run) |