Skip to content

Verifying webhook signatures

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 platform computes:

signature = HMAC_SHA256(signing_secret, request_body)

And sends it as X-IntelliAuth-Signature: <hex>. Your receiver must:

  1. Read the raw body (before any JSON parsing).
  2. Compute the same HMAC.
  3. Compare in constant time.
  4. 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))
}
import hmac, time
from 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)
end
function 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);
}

These come up over and over. Get them right once and they stop biting.

The body must be the EXACT bytes the platform signed. JSON parsing canonicalises whitespace; the HMAC will not match.

// Wrong
const body = req.body // already parsed by Express's JSON middleware
verify(JSON.stringify(body), sig, ts, secret) // re-stringified — different bytes!
// Right
app.use('/webhooks/intelliauth', express.raw({ type: 'application/json' }))
// req.body is now a Buffer
verify(req.body, sig, ts, secret)
// Wrong — leaks timing information; an attacker can probe character-by-character.
if (signature === expected) { /* ... */ }
// Right
if (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.

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:

  • Expressapp.use('/webhooks/intelliauth', express.raw({ type: 'application/json' })) before any other body parser for that route.
  • NestJS — set rawBody: true in the NestFactory.create options; access via req.rawBody.
  • FastAPI — declare the parameter as request: Request and read await request.body().
  • Spring — use byte[] as the parameter type, not a DTO.

The principle: get the raw bytes, verify, THEN parse.

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.

Common failure modes and what they mean:

SymptomMost likely cause
Every event failsWrong secret; or your framework is mutating the body
Random failuresBody parser modifying whitespace; multi-instance secret out of sync
Failures concentrated on retriesTimestamp check rejecting old retried events
Failures only on large payloadsBody 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.