NueForm

Webhook 签名验证

如何使用 HMAC-SHA256 验证 NueForm webhook 签名,包含 Node.js、Python、Go、PHP 和 Ruby 的代码示例。

NueForm 的每个 webhook 请求都包含一个加密签名,让您验证请求的真实性和完整性。您应始终在处理 webhook 数据之前验证此签名。

签名工作原理

当 NueForm 分发 webhook 时,它会:

  1. 将负载序列化为 JSON 字符串。
  2. 使用您的 webhook 密钥作为密钥,计算该 JSON 字符串的 HMAC-SHA256 哈希。
  3. 将哈希编码为小写十六进制字符串。
  4. X-NueForm-Signature HTTP 头中发送十六进制摘要。

在您这端,您对原始请求体执行相同的计算,并将结果与头中的值进行比较。如果匹配,请求是真实的。

签名头

text
X-NueForm-Signature: 5d41402abc4b2a76b9719d911017c592a3f6e7d4b9c1d5e8f2a7b3c6d9e0f1a2

头的值是一个 64 字符的十六进制字符串(HMAC-SHA256 的输出)。

您的 Webhook 密钥

您的 webhook 密钥是从 32 个随机字节生成的 64 字符十六进制字符串。它对您的账户唯一,在所有 webhook 端点(单表单和全局)之间共享。

获取您的密钥

通过 API 获取当前密钥:

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

响应:

json
{
  "secret": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
}

如果您还没有密钥,NueForm 会在首次请求时自动生成一个。

重新生成密钥

如果您的密钥被泄露,请立即重新生成:

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

重新生成密钥会立即使旧密钥失效。此后所有的 webhook 传递都将使用新密钥。请在重新生成之前或之后立即更新您的验证代码,以避免拒绝有效的 webhooks。

验证代码示例

Node.js

javascript
import crypto from 'crypto';

function verifyWebhookSignature(rawBody, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  // 使用时间安全比较防止计时攻击
  const a = Buffer.from(signature, 'hex');
  const b = Buffer.from(expected, 'hex');

  if (a.length !== b.length) {
    return false;
  }

  return crypto.timingSafeEqual(a, b);
}

// 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);

  // 异步处理 webhook
  processWebhookAsync(payload);

  res.status(200).send('OK');
});

使用 Express.js 时,您必须使用 express.raw()express.text() 来访问原始请求体。如果使用 express.json(),请求体将被解析并重新序列化,这可能产生与签名时不同的字符串——导致验证失败。

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()

    # 使用时间安全比较
    return hmac.compare_digest(expected, signature)


# 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']}")

    # 异步处理(例如加入 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 执行常量时间比较
	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
	}

	// 处理 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);

    // 使用时间安全比较
    return hash_equals($expected, $signature);
}

// 使用方法
$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']}");

// 处理 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)

  # 使用时间安全比较
  Rack::Utils.secure_compare(expected, signature)
end

# 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']}"

  # 异步处理
  WebhookProcessorJob.perform_async(payload)

  status 200
  body 'OK'
end

时间安全比较

以上所有代码示例在检查签名时都使用时间安全比较(也称为常量时间比较)。这是防止计时攻击的安全最佳实践。

计时攻击通过测量字符串比较花费的时间来工作。简单的 == 比较在发现第一个不匹配字符时就返回 false,因此攻击者可以通过测量响应延迟逐个字符了解签名。

时间安全比较函数无论匹配多少字符都花费相同的时间,使此攻击变得不可行。

语言函数
Node.jscrypto.timingSafeEqual()
Pythonhmac.compare_digest()
Gohmac.Equal()
PHPhash_equals()
RubyRack::Utils.secure_compare()

永远不要使用 =====.equals() 来比较 HMAC 签名。始终使用您所用语言的内置时间安全比较函数。

验证失败时的处理

如果签名验证失败,您的端点应:

  1. **返回 401 Unauthorized 状态码。**不要处理负载。
  2. 记录失败以便调试。包括请求 IP、时间戳和(可选的)收到的签名。
  3. 不要在错误消息或日志中暴露您的密钥。
  4. 排查常见原因:
    • **密钥错误。**确保您使用的是当前的 webhook 密钥。如果您最近重新生成了密钥,请更新验证代码。
    • **请求体转换。**确保您验证的是原始请求体,而不是解析后重新序列化的版本。在验证代码运行之前解析 JSON 的中间件是最常见的失败原因。
    • **编码问题。**原始请求体必须作为 UTF-8 字节处理。确保您的框架不会应用意外的编码转换。
    • **代理修改。**如果反向代理(例如 Cloudflare、nginx)修改了请求体,签名将不匹配。配置代理使其原样传递请求体。

故障排除检查清单

问题解决方案
签名从不匹配验证您读取的是原始请求体字节,而不是解析后的 JSON 对象
签名突然不匹配检查 webhook 密钥是否被重新生成
签名在本地匹配但在生产环境中不匹配检查代理或 CDN 的请求体转换
X-NueForm-Signature 头缺失确保您的框架保留自定义头(某些会去除 X- 前缀的头)

后续步骤

  • 测试 --- 在本地测试签名验证
  • 负载 --- 了解您正在验证的负载格式
  • 事件 --- 了解事件类型
最后更新:2026年4月6日