Every webhook delivery includes an HMAC-SHA256 signature in the X-Socifyr-Signature header. Verifying it ensures:
- The request actually came from Socifyr (not a spoofer)
- The body wasn't tampered with in transit
The header
http
X-Socifyr-Signature: t=1715731000,v1=4f9a8b...| Component | Meaning |
|---|---|
t=<unix_ts> | Unix timestamp when Socifyr signed the request |
v1=<hex> | HMAC-SHA256(secret, t.body) |
Verification (Node.js)
typescript
import crypto from 'crypto'
function verifySocifyrWebhook(
rawBody: string,
signatureHeader: string,
secret: string,
tolerance = 5 * 60 * 1000, // 5 minutes
): boolean {
const parts = Object.fromEntries(
signatureHeader.split(',').map(p => p.split('=')) as Array<[string, string]>,
)
const timestamp = parseInt(parts.t, 10)
const signature = parts.v1
// Reject if timestamp is too old (replay protection)
if (Math.abs(Date.now() - timestamp * 1000) > tolerance) {
return false
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
)
}Verification (other languages)
python
import hmac, hashlib, time
def verify(raw_body: bytes, signature: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split('=') for p in signature.split(','))
timestamp = int(parts['t'])
if abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.{raw_body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts['v1'])go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
"strconv"
"time"
)
func Verify(body, sig, secret string) bool {
parts := map[string]string{}
for _, p := range strings.Split(sig, ",") {
kv := strings.SplitN(p, "=", 2)
parts[kv[0]] = kv[1]
}
ts, _ := strconv.ParseInt(parts["t"], 10, 64)
if abs(time.Now().Unix()-ts) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(ts, 10) + "." + body))
return hmac.Equal([]byte(parts["v1"]), []byte(hex.EncodeToString(mac.Sum(nil))))
}
func abs(x int64) int64 { if x < 0 { return -x }; return x }ruby
require 'openssl'
def verify(body, sig, secret, tolerance = 300)
parts = sig.split(',').map { |p| p.split('=') }.to_h
ts = parts['t'].to_i
return false if (Time.now.to_i - ts).abs > tolerance
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{ts}.#{body}")
OpenSSL::Util.respond_to?(:secure_compare) ?
OpenSSL::Util.secure_compare(expected, parts['v1']) :
expected == parts['v1']
endWhy use the timestamp?
The timestamp prevents replay attacks — even if an attacker intercepts a valid request, they can't replay it five minutes later because the t value is part of the signed payload.
Common mistakes
- Parsing JSON before verifying. Always sign / verify the raw body bytes, not a re-serialized JSON string — extra whitespace or different key ordering will break the signature.
- Ignoring the timestamp. Without timestamp validation, an attacker who replays old requests can repeatedly trigger your handlers.
- Using
==for comparison. Always use a constant-time comparison (timingSafeEqual,hmac.compare_digest, etc.) to prevent timing attacks.