Verificar firma de webhook

Snippets Node.js, Python, Go y PHP para validar HMAC SHA-256.

HeaderDescripción
X-Soma9-Signaturesha256=<hex> del HMAC sobre ${timestamp}.${body}
X-Soma9-TimestampUnix seconds — rechazar si difiere >5 min del clock actual
X-Soma9-EventSlug del evento (domain.registered, etc)

Node.js

import crypto from 'node:crypto';
import express from 'express';
 
const app = express();
app.post('/webhooks/soma9', express.raw({ type: 'application/json' }), (req, res) => {
  const ts = req.headers['x-soma9-timestamp'];
  const sig = req.headers['x-soma9-signature'];
  const secret = process.env.SOMA9_WEBHOOK_SECRET;
 
  // Anti-replay
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
    return res.status(401).send('timestamp out of range');
  }
 
  const expected =
    'sha256=' +
    crypto.createHmac('sha256', secret).update(`${ts}.${req.body}`).digest('hex');
 
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(401).send('invalid signature');
  }
 
  const event = JSON.parse(req.body.toString('utf8'));
  console.log('event:', event.event, event.data);
  res.json({ received: true });
});

Python

import hmac, hashlib, time
from flask import Flask, request, abort
 
app = Flask(__name__)
SECRET = os.environ['SOMA9_WEBHOOK_SECRET'].encode()
 
@app.post('/webhooks/soma9')
def receive():
    raw = request.get_data()
    ts = request.headers['X-Soma9-Timestamp']
    sig = request.headers['X-Soma9-Signature']
 
    if abs(time.time() - int(ts)) > 300:
        abort(401, 'timestamp out of range')
 
    expected = 'sha256=' + hmac.new(SECRET, f'{ts}.'.encode() + raw, hashlib.sha256).hexdigest()
 
    if not hmac.compare_digest(sig, expected):
        abort(401, 'invalid signature')
 
    event = request.get_json()
    print('event:', event['event'], event['data'])
    return {'received': True}

Go

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "net/http"
    "strconv"
    "time"
)
 
func handle(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    ts := r.Header.Get("X-Soma9-Timestamp")
    sig := r.Header.Get("X-Soma9-Signature")
 
    tsInt, _ := strconv.ParseInt(ts, 10, 64)
    if math.Abs(float64(time.Now().Unix()-tsInt)) > 300 {
        http.Error(w, "timestamp out of range", 401)
        return
    }
 
    mac := hmac.New(sha256.New, []byte(os.Getenv("SOMA9_WEBHOOK_SECRET")))
    mac.Write([]byte(ts + "." + string(body)))
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
 
    if !hmac.Equal([]byte(sig), []byte(expected)) {
        http.Error(w, "invalid signature", 401)
        return
    }
    // process event
}

PHP

<?php
$secret = getenv('SOMA9_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_SOMA9_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_SOMA9_SIGNATURE'] ?? '';
 
if (abs(time() - (int)$ts) > 300) {
    http_response_code(401); exit('timestamp out of range');
}
$expected = 'sha256=' . hash_hmac('sha256', "$ts.$raw", $secret);
if (!hash_equals($sig, $expected)) {
    http_response_code(401); exit('invalid signature');
}
$event = json_decode($raw, true);
// process $event
echo json_encode(['received' => true]);

Tip

Usa siempre timing-safe compare (hmac.compare_digest, crypto.timingSafeEqual, hmac.Equal, hash_equals). Comparar strings con == expone tu app a timing attacks.