Every webhook delivery carries an HMAC-SHA256 signature. Verifying it proves the payload really came from IntelliAuth and wasn't tampered with in transit. Failure to verify is the single most common webhook security mistake — without it, anyone who guesses your endpoint URL can post arbitrary payloads.
The contract is small. The implementation is short. Get it right once and copy it forward.
The contract
Section titled “The contract”The platform computes:
signature = HMAC_SHA256(signing_secret, request_body)And sends it as X-IntelliAuth-Signature: <hex>. Your receiver must:
- Read the raw body (before any JSON parsing).
- Compute the same HMAC.
- Compare in constant time.
- If they match, the payload is authentic; otherwise reject with 401.
Plus a timestamp check:
X-IntelliAuth-Timestamp: <unix-seconds>carries the time of signing.- If the timestamp is more than 5 minutes old, treat it as a replay and reject.
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(rawBody: Buffer, signature: string, timestamp: string, secret: string): boolean { // 1. Timestamp check const ts = Number(timestamp) if (!Number.isFinite(ts)) return false const now = Math.floor(Date.now() / 1000) if (Math.abs(now - ts) > 5 * 60) return false
// 2. HMAC compare const expected = createHmac('sha256', secret).update(rawBody).digest('hex') if (signature.length !== expected.length) return false return timingSafeEqual(Buffer.from(signature), Buffer.from(expected))}Python
Section titled “Python”import hmac, timefrom hashlib import sha256
def verify(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool: try: ts = int(timestamp) except ValueError: return False if abs(int(time.time()) - ts) > 5 * 60: return False
expected = hmac.new(secret.encode(), raw_body, sha256).hexdigest() return hmac.compare_digest(signature, expected)import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "strconv" "time")
func verify(rawBody []byte, signature, timestamp, secret string) bool { ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return false } if abs(time.Now().Unix()-ts) > 5*60 { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(signature), []byte(expected))}require 'openssl'
def verify(raw_body, signature, timestamp, secret) ts = Integer(timestamp) rescue return false return false if (Time.now.to_i - ts).abs > 300
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body) Rack::Utils.secure_compare(signature, expected)endfunction verify(string $rawBody, string $signature, string $timestamp, string $secret): bool { $ts = (int) $timestamp; if (abs(time() - $ts) > 300) { return false; } $expected = hash_hmac('sha256', $rawBody, $secret); return hash_equals($signature, $expected);}The four traps
Section titled “The four traps”These come up over and over. Get them right once and they stop biting.
Trap 1: JSON.parse before verify
Section titled “Trap 1: JSON.parse before verify”The body must be the EXACT bytes the platform signed. JSON parsing canonicalises whitespace; the HMAC will not match.
// Wrongconst body = req.body // already parsed by Express's JSON middlewareverify(JSON.stringify(body), sig, ts, secret) // re-stringified — different bytes!
// Rightapp.use('/webhooks/intelliauth', express.raw({ type: 'application/json' }))// req.body is now a Bufferverify(req.body, sig, ts, secret)Trap 2: string comparison
Section titled “Trap 2: string comparison”// Wrong — leaks timing information; an attacker can probe character-by-character.if (signature === expected) { /* ... */ }
// Rightif (timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { /* ... */ }A vanishingly small minority of attacks exploit timing leaks on HMAC compare, but it costs nothing to do it correctly.
Trap 3: skipping the timestamp check
Section titled “Trap 3: skipping the timestamp check”Without it, an attacker who captured a valid signed event (e.g., from an HTTP proxy log) can replay it indefinitely. The 5-minute window is the platform's contract; a slightly tighter window on your side is fine, looser is not.
Trap 4: framework body-parser interactions
Section titled “Trap 4: framework body-parser interactions”Many frameworks parse JSON automatically on application/json. You must opt out for the webhook route specifically:
- Express —
app.use('/webhooks/intelliauth', express.raw({ type: 'application/json' }))before any other body parser for that route. - NestJS — set
rawBody: truein theNestFactory.createoptions; access viareq.rawBody. - FastAPI — declare the parameter as
request: Requestand readawait request.body(). - Spring — use
byte[]as the parameter type, not a DTO.
The principle: get the raw bytes, verify, THEN parse.
Multi-secret rotation
Section titled “Multi-secret rotation”When you rotate the signing secret (next topic), there's a brief window where both old and new are valid. Verify against both:
function verify(rawBody, signature, timestamp, currentSecret, previousSecret) { if (verifyOne(rawBody, signature, timestamp, currentSecret)) return true if (previousSecret && verifyOne(rawBody, signature, timestamp, previousSecret)) return true return false}The platform stamps the delivery with which secret it used, exposed via X-IntelliAuth-Signing-Key-Id, so you can also branch on the header rather than trying both.
When verification fails
Section titled “When verification fails”Common failure modes and what they mean:
| Symptom | Most likely cause |
|---|---|
| Every event fails | Wrong secret; or your framework is mutating the body |
| Random failures | Body parser modifying whitespace; multi-instance secret out of sync |
| Failures concentrated on retries | Timestamp check rejecting old retried events |
| Failures only on large payloads | Body parser stream truncation; check max body size |
Log the failure (without logging the body — it might contain PII), bump a metric, and let the platform retry. Don't crash.