Cada solicitud de webhook de NueForm incluye una firma criptográfica que te permite verificar que la solicitud es auténtica y no ha sido manipulada. Siempre debes verificar esta firma antes de procesar datos de webhook.
Cómo Funciona la Firma
Cuando NueForm despacha un webhook:
- Serializa el payload como un string JSON.
- Calcula un hash HMAC-SHA256 de ese string JSON usando tu secreto de webhook como clave.
- Codifica el hash como un string hexadecimal en minúsculas.
- Envía el digest hexadecimal en el encabezado HTTP
X-NueForm-Signature.
De tu lado, realizas el mismo cálculo sobre el cuerpo crudo de la solicitud y comparas tu resultado con el valor del encabezado. Si coinciden, la solicitud es genuina.
El Encabezado de Firma
X-NueForm-Signature: 5d41402abc4b2a76b9719d911017c592a3f6e7d4b9c1d5e8f2a7b3c6d9e0f1a2
El valor del encabezado es un string hexadecimal de 64 caracteres (la salida de HMAC-SHA256).
Tu Secreto de Webhook
Tu secreto de webhook es un string hexadecimal de 64 caracteres generado a partir de 32 bytes aleatorios. Es único para tu cuenta y compartido entre todos tus endpoints de webhook (tanto por formulario como globales).
Recuperar Tu Secreto
Obtén tu secreto actual via la API:
curl https://app.nueform.com/api/v1/webhooks/secret \
-H "Authorization: Bearer nf_your_api_key"
Respuesta:
{
"secret": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
}
Si aún no tienes un secreto, NueForm genera uno automáticamente en la primera solicitud.
Regenerar Tu Secreto
Si tu secreto se ve comprometido, regénéralo inmediatamente:
curl -X POST https://app.nueform.com/api/v1/webhooks/secret/regenerate \
-H "Authorization: Bearer nf_your_api_key"
Regenerar tu secreto invalida inmediatamente el anterior. Todas las entregas de webhook a partir de ese punto usarán el nuevo secreto. Actualiza tu código de verificación antes o inmediatamente después de regenerar para evitar rechazar webhooks válidos.
Ejemplos de Código de Verificación
Node.js
import crypto from 'crypto';
function verifyWebhookSignature(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// Usar comparación segura en tiempo para prevenir ataques de temporización
const a = Buffer.from(signature, 'hex');
const b = Buffer.from(expected, 'hex');
if (a.length !== b.length) {
return false;
}
return crypto.timingSafeEqual(a, b);
}
// Ejemplo con Express.js
app.post('/webhooks/nueform', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-nueform-signature'];
const rawBody = req.body.toString();
if (!verifyWebhookSignature(rawBody, signature, process.env.NUEFORM_WEBHOOK_SECRET)) {
console.error('Invalid webhook signature');
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(rawBody);
console.log('Verified webhook:', payload.event, payload.responseId);
// Procesar el webhook de forma asíncrona
processWebhookAsync(payload);
res.status(200).send('OK');
});
Cuando uses Express.js, debes usar express.raw() o express.text() para acceder al cuerpo crudo de la solicitud. Si usas express.json(), el cuerpo será analizado y re-serializado, lo que puede producir un string diferente al que fue firmado --- causando que la verificación falle.
Python
import hashlib
import hmac
def verify_webhook_signature(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode('utf-8'),
raw_body,
hashlib.sha256
).hexdigest()
# Usar comparación segura en tiempo
return hmac.compare_digest(expected, signature)
# Ejemplo con Flask
from flask import Flask, request, abort
app = Flask(__name__)
@app.route('/webhooks/nueform', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-NueForm-Signature', '')
raw_body = request.get_data()
if not verify_webhook_signature(raw_body, signature, WEBHOOK_SECRET):
abort(401, 'Invalid signature')
payload = request.get_json()
print(f"Verified webhook: {payload['event']} {payload['responseId']}")
# Procesar de forma asíncrona (por ejemplo, encolar en Celery)
process_webhook.delay(payload)
return 'OK', 200
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
)
func verifyWebhookSignature(body []byte, signature string, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
// hmac.Equal realiza una comparación en tiempo constante
return hmac.Equal([]byte(expected), []byte(signature))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
defer r.Body.Close()
signature := r.Header.Get("X-NueForm-Signature")
if !verifyWebhookSignature(body, signature, webhookSecret) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Procesar payload de webhook
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
PHP
<?php
function verifyWebhookSignature(string $rawBody, string $signature, string $secret): bool {
$expected = hash_hmac('sha256', $rawBody, $secret);
// Usar comparación segura en tiempo
return hash_equals($expected, $signature);
}
// Uso
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_NUEFORM_SIGNATURE'] ?? '';
$secret = getenv('NUEFORM_WEBHOOK_SECRET');
if (!verifyWebhookSignature($rawBody, $signature, $secret)) {
http_response_code(401);
echo 'Invalid signature';
exit;
}
$payload = json_decode($rawBody, true);
error_log("Verified webhook: {$payload['event']} {$payload['responseId']}");
// Procesar el webhook
processWebhook($payload);
http_response_code(200);
echo 'OK';
Ruby
require 'openssl'
require 'json'
def verify_webhook_signature(raw_body, signature, secret)
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body)
# Usar comparación segura en tiempo
Rack::Utils.secure_compare(expected, signature)
end
# Ejemplo con Sinatra
post '/webhooks/nueform' do
raw_body = request.body.read
signature = request.env['HTTP_X_NUEFORM_SIGNATURE'] || ''
unless verify_webhook_signature(raw_body, signature, ENV['NUEFORM_WEBHOOK_SECRET'])
halt 401, 'Invalid signature'
end
payload = JSON.parse(raw_body)
logger.info "Verified webhook: #{payload['event']} #{payload['responseId']}"
# Procesar de forma asíncrona
WebhookProcessorJob.perform_async(payload)
status 200
body 'OK'
end
Comparación Segura en Tiempo
Todos los ejemplos de código anteriores usan comparación segura en tiempo (también llamada comparación en tiempo constante) al verificar firmas. Esta es una mejor práctica de seguridad que previene ataques de temporización.
Un ataque de temporización funciona midiendo cuánto tiempo tarda una comparación de strings. Una comparación ingenua con == devuelve false tan pronto como encuentra el primer carácter que no coincide, por lo que un atacante podría aprender un carácter de la firma a la vez midiendo la latencia de respuesta.
Las funciones de comparación segura en tiempo siempre tardan la misma cantidad de tiempo independientemente de cuántos caracteres coincidan, haciendo este ataque inviable.
| Lenguaje | Función |
|---|---|
| Node.js | crypto.timingSafeEqual() |
| Python | hmac.compare_digest() |
| Go | hmac.Equal() |
| PHP | hash_equals() |
| Ruby | Rack::Utils.secure_compare() |
Nunca uses ===, == o .equals() para comparar firmas HMAC. Siempre usa la función de comparación segura en tiempo incorporada de tu lenguaje.
Qué Hacer Si la Verificación Falla
Si la verificación de firma falla, tu endpoint debe:
- Devolver un código de estado
401 Unauthorized. No proceses el payload. - Registra el fallo para depuración. Incluye la IP de la solicitud, marca de tiempo y (opcionalmente) la firma recibida.
- No expongas tu secreto en mensajes de error o registros.
- Investiga causas comunes:
- Secreto incorrecto. Asegúrate de estar usando el secreto de webhook actual. Si lo regeneraste recientemente, actualiza tu código de verificación.
- Transformación del cuerpo. Asegúrate de verificar contra el cuerpo crudo de la solicitud, no una versión analizada y re-serializada. El middleware que analiza JSON antes de que se ejecute tu código de verificación es la causa más común de fallos.
- Problemas de codificación. El cuerpo crudo debe tratarse como bytes UTF-8. Asegúrate de que tu framework no aplique transformaciones de codificación inesperadas.
- Modificación del proxy. Si un proxy inverso (por ejemplo, Cloudflare, nginx) está modificando el cuerpo de la solicitud, la firma no coincidirá. Configura tu proxy para pasar el cuerpo sin cambios.
Lista de Verificación de Solución de Problemas
| Problema | Solución |
|---|---|
| La firma nunca coincide | Verifica que estás leyendo los bytes crudos del cuerpo, no un objeto JSON analizado |
| La firma dejó de coincidir repentinamente | Verifica si tu secreto de webhook fue regenerado |
| La firma coincide localmente pero no en producción | Verifica transformación del cuerpo por proxy o CDN |
Falta el encabezado X-NueForm-Signature | Asegúrate de que tu framework preserva los encabezados personalizados (algunos eliminan encabezados con prefijo X-) |