Verificación HMAC
🔒 OBLIGATORIO. Sin verificar la firma, cualquier atacante que adivine tu URL podría enviarte webhooks falsos y disparar tu lógica de negocio con datos inventados.
Cada POST de webhook trae el header X-Lxs-Signature con la firma HMAC-SHA256 del body crudo, calculada con el webhookSecret de tu API key. Tu trabajo es recomputar el HMAC y compararlo, abortando con 401 si no coincide.
Algoritmo
expected = HEX(HMAC-SHA256(webhookSecret, raw_body))
header = "sha256=" + expectedComparalo con el header X-Lxs-Signature (sin el prefijo sha256=) usando comparación timing-safe.
Reglas críticas
- Capturá el body crudo antes de parsearlo. No re-serialices el JSON ya parseado: el orden de claves y los espacios pueden cambiar y romper el HMAC.
- Compará con una función timing-safe (nunca con
===ni==). - Si no coincide, respondé
401y descartá el evento. - Si coincide, procesá el evento (idealmente de forma asíncrona para responder rápido).
Ejemplo Node.js (Express)
Es importante registrar el middleware express.raw({ type: 'application/json' }) antes de cualquier parser JSON global; de lo contrario req.body ya viene serializado de vuelta y la firma falla.
import express from 'express';
import { createHmac, timingSafeEqual } from 'crypto';
const app = express();
// IMPORTANTE: raw body, no JSON automático
app.post('/webhooks/lxs',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = (req.headers['x-lxs-signature'] || '').replace(/^sha256=/, '');
const expected = createHmac('sha256', process.env.LXS_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (
signature.length !== expected.length ||
!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
procesarEvento(event);
res.status(200).end();
}
);Ejemplo Python (Flask)
En Flask usá request.get_data() para conseguir el body en bytes; jamás reuses lo que devuelve get_json() para calcular el HMAC.
import os, hmac, hashlib
from flask import request, abort, Flask
app = Flask(__name__)
@app.post('/webhooks/lxs')
def webhook():
signature = (request.headers.get('X-Lxs-Signature') or '').replace('sha256=', '')
expected = hmac.new(
os.environ['LXS_WEBHOOK_SECRET'].encode(),
request.get_data(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
event = request.get_json()
procesar_evento(event)
return '', 200Ejemplo C# (.NET)
En ASP.NET conviene leer el body directamente de Request.Body con un StreamReader y usar CryptographicOperations.FixedTimeEquals para la comparación timing-safe.
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("webhooks/lxs")]
public class LxsWebhookController : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Handle()
{
// Captura raw body
using StreamReader sr = new(Request.Body);
string body = await sr.ReadToEndAsync();
string presented = Request.Headers["X-Lxs-Signature"]
.ToString()
.Replace("sha256=", "", StringComparison.OrdinalIgnoreCase);
byte[] secret = Encoding.UTF8.GetBytes(
Environment.GetEnvironmentVariable("LXS_WEBHOOK_SECRET")!);
byte[] expected = HMACSHA256.HashData(secret, Encoding.UTF8.GetBytes(body));
string expectedHex = Convert.ToHexString(expected).ToLowerInvariant();
if (
presented.Length != expectedHex.Length ||
!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(presented),
Encoding.UTF8.GetBytes(expectedHex)))
{
return Unauthorized();
}
var evento = JsonSerializer.Deserialize<HaciendaEvent>(body)!;
await ProcesarEventoAsync(evento);
return Ok();
}
}Ejemplo PHP
En PHP, file_get_contents('php://input') te da el body crudo y hash_equals hace la comparación timing-safe.
<?php
$rawBody = file_get_contents('php://input');
$signature = str_replace('sha256=', '', $_SERVER['HTTP_X_LXS_SIGNATURE'] ?? '');
$expected = hash_hmac('sha256', $rawBody, getenv('LXS_WEBHOOK_SECRET'));
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
$evento = json_decode($rawBody, true);
procesar_evento($evento);
http_response_code(200);