I IntelliAuth Docs

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

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.

Node

ts
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

python
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)

Go

go
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))
}

Ruby

ruby
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

PHP

php
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);
}

The four traps

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

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.

ts
// 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)

Trap 2: string comparison

ts
// 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.

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

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.

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:

ts
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

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.

See also