Verifying Signatures
Every outbound webhook from Kirim carries an HMAC signature in the
X-Kirim-Signature header. You MUST verify it — without
verification, anyone who learns your endpoint URL can fire fake events
at you.
Header format
Section titled “Header format”X-Kirim-Signature: t=1716480000,v1=<hex>[,v1=<hex>...]| Component | Meaning |
|---|---|
t=… | Unix epoch seconds when Kirim signed the payload. |
v1=… | Hex-encoded HMAC-SHA256 of "{t}.{raw_body}" keyed by your subscription secret. |
Multiple v1= segments appear during secret rotation — Kirim
signs with every active secret simultaneously, so you can verify
against either while you roll your stored secret over.
Verification recipe
Section titled “Verification recipe”The signed payload is "{t}.{raw_body}" — literal dot, no separator
spaces, raw bytes of the body as received (not pretty-printed).
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyKirimSignature( rawBody: string, header: string | undefined, secrets: string[], toleranceSeconds = 300,): boolean { if (!header) return false
const parts = Object.fromEntries( header.split(',').map((p) => { const [k, ...v] = p.trim().split('=') return [k, v.join('=')] }), ) as { t?: string; v1?: string }
// Header may have multiple v1= — re-split to collect all. const v1s = header .split(',') .map((p) => p.trim()) .filter((p) => p.startsWith('v1=')) .map((p) => p.slice(3))
const t = Number(parts.t) if (!t || v1s.length === 0) return false
// Replay protection — reject signatures older than 5 minutes. const ageSec = Math.abs(Date.now() / 1000 - t) if (ageSec > toleranceSeconds) return false
const signed = `${t}.${rawBody}`
// Verify against ALL active secrets. Pass if ANY match — secret // rotation requires accepting both old and new during the overlap. return secrets.some((secret) => { const expected = createHmac('sha256', secret).update(signed).digest('hex') return v1s.some((received) => { const a = Buffer.from(expected, 'hex') const b = Buffer.from(received, 'hex') return a.length === b.length && timingSafeEqual(a, b) }) })}import hmac, hashlib, time
def verify_kirim_signature( raw_body: bytes, header: str | None, secrets: list[str], tolerance_seconds: int = 300,) -> bool: if not header: return False
parts = {} for p in header.split(','): k, _, v = p.strip().partition('=') parts.setdefault(k, []).append(v)
try: t = int(parts['t'][0]) except (KeyError, IndexError, ValueError): return False
v1s = parts.get('v1', []) if not v1s: return False
if abs(time.time() - t) > tolerance_seconds: return False # too old / clock skew
signed = f'{t}.'.encode() + raw_body
for secret in secrets: expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() for received in v1s: if hmac.compare_digest(expected, received): return True return Falserequire 'openssl'
def verify_kirim_signature(raw_body, header, secrets, tolerance_seconds: 300) return false if header.nil?
parts = header.split(',').each_with_object({ t: nil, v1: [] }) do |p, acc| k, v = p.strip.split('=', 2) if k == 't' then acc[:t] = v.to_i elsif k == 'v1' then acc[:v1] << v end end return false unless parts[:t] && !parts[:v1].empty?
return false if (Time.now.to_i - parts[:t]).abs > tolerance_seconds
signed = "#{parts[:t]}.#{raw_body}" secrets.any? do |secret| expected = OpenSSL::HMAC.hexdigest('SHA256', secret, signed) parts[:v1].any? { |received| OpenSSL.fixed_length_secure_compare(expected, received) } endendpackage main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "math" "strconv" "strings" "time")
func VerifyKirimSignature( rawBody []byte, header string, secrets []string, toleranceSeconds float64,) bool { if header == "" { return false }
var t int64 var v1s []string for _, part := range strings.Split(header, ",") { kv := strings.SplitN(strings.TrimSpace(part), "=", 2) if len(kv) != 2 { continue } switch kv[0] { case "t": t, _ = strconv.ParseInt(kv[1], 10, 64) case "v1": v1s = append(v1s, kv[1]) } } if t == 0 || len(v1s) == 0 { return false } if math.Abs(float64(time.Now().Unix()-t)) > toleranceSeconds { return false }
signed := []byte(strconv.FormatInt(t, 10) + ".") signed = append(signed, rawBody...)
for _, secret := range secrets { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(signed) expected := hex.EncodeToString(mac.Sum(nil)) for _, received := range v1s { if hmac.Equal([]byte(expected), []byte(received)) { return true } } } return false}Express webhook handler
Section titled “Express webhook handler”import express from 'express'import { verifyKirimSignature } from './kirim.js'
const app = express()
app.post( '/kirim-webhook', express.raw({ type: 'application/json' }), // raw bytes preserved (req, res) => { const ok = verifyKirimSignature( req.body.toString('utf8'), req.headers['x-kirim-signature'] as string, [process.env.KIRIM_SECRET_PRIMARY!, process.env.KIRIM_SECRET_ROTATING!].filter(Boolean), ) if (!ok) return res.status(401).send('invalid signature')
const payload = JSON.parse(req.body.toString('utf8')) // ... process event res.status(200).send('ok') },)Multi-secret rotation
Section titled “Multi-secret rotation”When you rotate a secret via the dashboard or API, Kirim:
- Generates a new secret. Returns the plaintext once.
- For the duration of the overlap window (default 72 hours), signs
every outbound delivery with both secrets — two
v1=segments in the header. - After the overlap or on explicit revoke, signs with only the new one.
Roll your stored secret over inside that 72-hour window:
- Store the new secret alongside the old one.
- Verify against both (the recipe above does this).
- Once you’ve confirmed the new secret works, remove the old one from storage.
- Revoke the old secret via the dashboard or
DELETE /v1/webhook_subscriptions/{id}/secrets/{secret_id}.
Replay protection
Section titled “Replay protection”The recipe rejects signatures older than 5 minutes. This guards
against an attacker who captures a signed delivery and replays it
weeks later — the timestamp t won’t validate.
If your endpoint is geographically far from Kirim’s region, increase
toleranceSeconds to account for clock drift, but keep it under 10
minutes.
Anti-patterns
Section titled “Anti-patterns”- Don’t roll your own HMAC compare. Always use the language’s
timing-safe compare (
crypto.timingSafeEqual,hmac.compare_digest,OpenSSL.fixed_length_secure_compare,hmac.Equal). - Don’t skip verification because “the URL is secret”. URLs leak — via DNS logs, browser history, support tickets, accidental screenshots, vendor breaches.
- Don’t verify after parsing JSON. As above — verify raw bytes first, parse second.
- Don’t ignore the timestamp. A signature without freshness check is a replay-attack invitation.