This document describes the implementation of HMAC-SHA256 signature verification for inbound webhook routes in src/webhooks/webhook.routes.ts.
#318 - Enforce HMAC signature verification on inbound webhook routes
- Verifies HMAC-SHA256 signature header against raw request body
- Uses header format:
X-Callora-Signature-256: sha256=<hex> - Returns 401 Unauthorized for invalid or missing signatures
- Enforces configurable timestamp tolerance window (default: 5 minutes)
- Validates timestamp format (ISO-8601)
- Rejects requests with stale or future timestamps outside tolerance window
- Returns 401 Unauthorized for out-of-window timestamps
- Uses Node.js
crypto.timingSafeEqual()for constant-time comparison - Prevents timing-based attacks that could leak signature information
- Safe against timing side-channel attacks
- Opt-in feature (backwards compatible with webhooks registered without a secret)
- Raw request body captured before JSON parsing
- Comprehensive error handling with specific error codes
- Proper error messages for debugging without leaking sensitive information
src/webhooks/webhook.signature.ts
Exports:
computeSignature(secret, timestamp, rawBody)— Compute expected HMAC-SHA256safeCompare(a, b)— Timing-safe hex string comparisonverifyWebhookSignature()— Express middleware for signature verificationcaptureRawBody()— Express middleware to capture raw bytes before JSON parsing
Constants:
SIGNATURE_HEADER = 'x-callora-signature-256'TIMESTAMP_HEADER = 'x-callora-timestamp'SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000(5 minutes, configurable)
src/webhooks/webhook.routes.ts
Integration:
- Route:
POST /api/webhooks/deliver/:developerId - Middleware chain:
captureRawBody— Buffers raw request body- Secret lookup — Attaches developer's stored secret to request
verifyWebhookSignature— Verifies HMAC and timestampexpress.json()— Parses verified body- Request handler — Processes authenticated webhook
src/webhooks/webhook.signature.test.ts
Test categories (90%+ coverage):
-
computeSignature (6 tests)
- Correct format (64-char hex string)
- Deterministic behavior
- Sensitivity to secret, timestamp, and body changes
- Accepts both Buffer and string inputs
-
safeCompare (3 tests)
- Identical hex strings return true
- Different hex strings return false
- Length difference rejection
-
verifyWebhookSignature — No-op Path (1 test)
- Skips verification when no secret is configured
-
verifyWebhookSignature — Header Validation (7 tests)
- Missing signature header (401)
- Missing timestamp header (401)
- Non-ISO timestamp format (400)
- Stale timestamp — too old (401)
- Future timestamp outside tolerance (401)
- Malformed signature header without
sha256=prefix (400) - Wrong hash algorithm prefix (e.g.,
md5=, 400)
-
verifyWebhookSignature — Signature Mismatch (2 tests)
- Wrong secret produces mismatched signature (401)
- Tampered body produces mismatched signature (401)
-
verifyWebhookSignature — Happy Path (3 tests)
- Valid signature passes verification
- Empty request body handled correctly
- Undefined rawBody falls back to empty buffer
-
captureRawBody (3 tests)
- Captures streamed data into Buffer
- Handles empty body
- Forwards stream errors to next middleware
Total: 25+ unit tests, organized by functionality
| Criterion | Status | Verification |
|---|---|---|
| Invalid signatures rejected with 401 | ✅ | test('verifyWebhookSignature rejects when HMAC does not match') |
| Missing signatures rejected with 401 | ✅ | test('verifyWebhookSignature rejects when signature header is missing') |
| Stale timestamps rejected | ✅ | test('verifyWebhookSignature rejects a stale timestamp (too old)') |
| Future timestamps rejected | ✅ | test('verifyWebhookSignature rejects a future timestamp outside tolerance') |
| Timing-safe comparison used | ✅ | crypto.timingSafeEqual() in safeCompare() |
| Minimum 90% test coverage | ✅ | 25+ comprehensive unit tests |
| Documented | ✅ | Inline comments, docs/webhooks.md, this file |
| Error Code | HTTP Status | Scenario |
|---|---|---|
MISSING_WEBHOOK_SIGNATURE_HEADERS |
401 | Missing signature or timestamp header |
INVALID_WEBHOOK_TIMESTAMP |
400 | Non-ISO-8601 timestamp format |
WEBHOOK_TIMESTAMP_OUT_OF_WINDOW |
401 | Timestamp outside 5-minute tolerance |
MALFORMED_WEBHOOK_SIGNATURE |
400 | Signature header missing sha256= prefix |
INVALID_WEBHOOK_SIGNATURE |
401 | HMAC comparison failed (signature mismatch) |
- Default: 5 minutes (
SIGNATURE_TOLERANCE_MS) - Configurable at module load time
- Prevents replay attacks while allowing for clock skew
- Checked symmetrically (too old OR too far in future)
crypto.timingSafeEqual()ensures comparison time is independent of signature content- Length check is done upfront (no timing leak beyond length)
- Prevents attackers from using timing measurements to forge signatures
- Middleware is a no-op when no secret is configured
- Webhooks registered without a secret continue to work
- Supports gradual rollout of signature verification
captureRawBodymiddleware must be mounted BEFOREexpress.json()- Raw bytes are consumed by the request stream and stored in
req.rawBody - This ensures the exact bytes sent by the client are verified (no whitespace/encoding issues)
Edit src/webhooks/webhook.signature.ts:
export const SIGNATURE_TOLERANCE_MS = 10 * 60 * 1000; // 10 minutes instead of 5Run webhook signature verification tests:
npm test -- src/webhooks/webhook.signature.test.tsRun all webhook-related tests:
npm test -- src/webhooks/View test coverage:
npm run test:coverage -- src/webhooks/Request:
curl -X POST https://api.callora.dev/api/webhooks \
-H "Content-Type: application/json" \
-d '{
"developerId": "dev_abc123",
"url": "https://your-domain.com/webhooks/callora",
"events": ["new_api_call", "settlement_completed"],
"secret": "your-webhook-secret-key"
}'Implementation (Node.js/Express):
import crypto from 'crypto';
app.post('/webhooks/callora', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-callora-signature-256'];
const timestamp = req.headers['x-callora-timestamp'];
if (!signature || !timestamp) {
return res.status(401).json({ error: 'Missing signature headers' });
}
// Reconstruct signed payload
const signed = `${timestamp}.${req.body.toString()}`;
// Verify signature
const expected = `sha256=${crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(signed)
.digest('hex')}`;
try {
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
// Signature valid, process webhook
res.json({ status: 'ok' });
} catch {
res.status(401).json({ error: 'Invalid signature' });
}
});- Issue: #318
- Feature Branch:
feature/webhook-signature-verification-docs - Tests: 25+ unit tests with 90%+ coverage
- Status: Implementation complete and tested