Verifying signatures
Check the signature before you parse the body.
Every delivery carries these headers:
X-CallLens-Signature: t=1754384040,v1=8f3c...
X-CallLens-Event: call.completed
X-CallLens-Idempotency-Key: 3f9a1c22-...
X-CallLens-Delivery-Attempt: 1The scheme
v1 is HMAC-SHA256(secret, "{t}.{raw body}"), hex-encoded.
The timestamp is inside the MAC. A timestamp header that is not covered by the signature is
decoration — an attacker replaying a captured delivery just edits it to now and the signature
still verifies. Because t is signed, rejecting old timestamps actually rejects replays.
Three rules:
- Verify against the raw request body, before any JSON parsing or re-serialization. The MAC covers the exact bytes sent, not a canonical form.
- Look
v1up by name. Do not split on,and take the second field. A futurev2will appear alongsidev1during a migration window, and a positional parser breaks the moment it does. - Compare in constant time.
==on a hex MAC is a timing oracle.
Reject anything older than 300 seconds. That is a replay bound, not a clock-skew allowance.
PHP
function verify(string $header, string $rawBody, string $secret, int $tolerance = 300): bool
{
$fields = [];
foreach (explode(',', $header) as $pair) {
$parts = explode('=', trim($pair), 2);
if (count($parts) === 2) {
$fields[$parts[0]] = $parts[1];
}
}
if (! isset($fields['t'], $fields['v1']) || ! ctype_digit($fields['t'])) {
return false;
}
if (abs(time() - (int) $fields['t']) > $tolerance) {
return false;
}
return hash_equals(
hash_hmac('sha256', $fields['t'].'.'.$rawBody, $secret),
$fields['v1'],
);
}Node
const crypto = require('crypto');
function verify(header, rawBody, secret, tolerance = 300) {
const fields = Object.fromEntries(
header.split(',').map((p) => {
const i = p.indexOf('=');
return [p.slice(0, i).trim(), p.slice(i + 1)];
}),
);
if (!fields.t || !fields.v1 || !/^\d+$/.test(fields.t)) return false;
if (Math.abs(Date.now() / 1000 - Number(fields.t)) > tolerance) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${fields.t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(fields.v1, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}In Express, express.json() discards the raw body. Capture it first:
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); } })).
Verifying against JSON.stringify(req.body) will fail, intermittently and confusingly.
Python
import hashlib
import hmac
import time
def verify(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
fields = {}
for pair in header.split(","):
key, _, value = pair.strip().partition("=")
if value:
fields[key] = value
if "t" not in fields or "v1" not in fields or not fields["t"].isdigit():
return False
if abs(time.time() - int(fields["t"])) > tolerance:
return False
expected = hmac.new(
secret.encode(),
f"{fields['t']}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, fields["v1"])Go
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
func Verify(header string, rawBody []byte, secret string, tolerance int64) bool {
fields := map[string]string{}
for _, pair := range strings.Split(header, ",") {
if k, v, ok := strings.Cut(strings.TrimSpace(pair), "="); ok {
fields[k] = v
}
}
ts, err := strconv.ParseInt(fields["t"], 10, 64)
if err != nil || fields["v1"] == "" {
return false
}
if diff := time.Now().Unix() - ts; diff > tolerance || diff < -tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(fields["t"] + "."))
mac.Write(rawBody)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(fields["v1"]))
}