NueForm

Verificación de Firma de Webhook

Cómo verificar las firmas de webhook de NueForm usando HMAC-SHA256, con ejemplos de código en Node.js, Python, Go, PHP y Ruby.

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:

  1. Serializa el payload como un string JSON.
  2. Calcula un hash HMAC-SHA256 de ese string JSON usando tu secreto de webhook como clave.
  3. Codifica el hash como un string hexadecimal en minúsculas.
  4. 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

text
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:

bash
curl https://app.nueform.com/api/v1/webhooks/secret \
  -H "Authorization: Bearer nf_your_api_key"

Respuesta:

json
{
  "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:

bash
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

javascript
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

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

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
<?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

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.

LenguajeFunción
Node.jscrypto.timingSafeEqual()
Pythonhmac.compare_digest()
Gohmac.Equal()
PHPhash_equals()
RubyRack::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:

  1. Devolver un código de estado 401 Unauthorized. No proceses el payload.
  2. Registra el fallo para depuración. Incluye la IP de la solicitud, marca de tiempo y (opcionalmente) la firma recibida.
  3. No expongas tu secreto en mensajes de error o registros.
  4. 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

ProblemaSolución
La firma nunca coincideVerifica que estás leyendo los bytes crudos del cuerpo, no un objeto JSON analizado
La firma dejó de coincidir repentinamenteVerifica si tu secreto de webhook fue regenerado
La firma coincide localmente pero no en producciónVerifica transformación del cuerpo por proxy o CDN
Falta el encabezado X-NueForm-SignatureAsegúrate de que tu framework preserva los encabezados personalizados (algunos eliminan encabezados con prefijo X-)

Próximos Pasos

  • Pruebas --- Prueba la verificación de firma localmente
  • Payloads --- Entiende el formato del payload que estás verificando
  • Eventos --- Aprende sobre los tipos de eventos
Ultima actualizacion: 6 de abril de 2026