NueForm

Webhook-Signaturprüfung

So verifizierst du NueForm-Webhook-Signaturen mit HMAC-SHA256 — mit Codebeispielen in Node.js, Python, Go, PHP und Ruby.

Jede Webhook-Anfrage von NueForm enthält eine kryptografische Signatur, mit der du prüfen kannst, ob die Anfrage echt ist und nicht manipuliert wurde. Du solltest diese Signatur immer verifizieren, bevor du Webhook-Daten verarbeitest.

So funktioniert die Signierung

Wenn NueForm einen Webhook versendet, passiert Folgendes:

  1. Die Payload wird als JSON-String serialisiert.
  2. Aus diesem JSON-String wird ein HMAC-SHA256-Hash berechnet, mit deinem Webhook-Secret als Schlüssel.
  3. Der Hash wird als Hexadezimal-String in Kleinbuchstaben codiert.
  4. Der Hex-Digest wird im HTTP-Header X-NueForm-Signature mitgesendet.

Auf deiner Seite führst du dieselbe Berechnung auf dem rohen Anfrage-Body durch und vergleichst dein Ergebnis mit dem Header-Wert. Stimmen sie überein, ist die Anfrage echt.

Der Signatur-Header

text
X-NueForm-Signature: 5d41402abc4b2a76b9719d911017c592a3f6e7d4b9c1d5e8f2a7b3c6d9e0f1a2

Der Header-Wert ist ein 64-stelliger Hexadezimal-String (die Ausgabe von HMAC-SHA256).

Dein Webhook-Secret

Dein Webhook-Secret ist ein 64-stelliger Hexadezimal-String, erzeugt aus 32 zufälligen Bytes. Es ist einzigartig für dein Konto und gilt für alle deine Webhook-Endpunkte (formularbezogen und global).

Secret abrufen

Rufe dein aktuelles Secret über die API ab:

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

Antwort:

json
{
  "secret": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
}

Wenn du noch kein Secret hast, generiert NueForm bei der ersten Anfrage automatisch eines.

Secret neu generieren

Wenn dein Secret kompromittiert wurde, generiere es sofort neu:

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

Das Neugenerieren macht das alte Secret sofort ungültig. Alle Webhook-Zustellungen ab diesem Zeitpunkt verwenden das neue Secret. Aktualisiere deinen Verifizierungscode vor oder unmittelbar nach dem Neugenerieren, damit du keine gültigen Webhooks ablehnst.

Codebeispiele zur Verifizierung

Node.js

javascript
import crypto from 'crypto';

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

  // Use timing-safe comparison to prevent timing attacks
  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 example
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);

  // Process the webhook asynchronously
  processWebhookAsync(payload);

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

Mit Express.js musst du express.raw() oder express.text() verwenden, um an den rohen Anfrage-Body zu kommen. Nutzt du express.json(), wird der Body geparst und neu serialisiert — dabei kann ein anderer String entstehen als der signierte, und die Verifizierung schlägt fehl.

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

    # Use timing-safe comparison
    return hmac.compare_digest(expected, signature)


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

    # Process asynchronously (e.g., enqueue to 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 performs a constant-time comparison
	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
	}

	// Process webhook payload
	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);

    // Use timing-safe comparison
    return hash_equals($expected, $signature);
}

// Usage
$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']}");

// Process the 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)

  # Use timing-safe comparison
  Rack::Utils.secure_compare(expected, signature)
end

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

  # Process asynchronously
  WebhookProcessorJob.perform_async(payload)

  status 200
  body 'OK'
end

Zeitkonstanter Vergleich

Alle obigen Codebeispiele verwenden beim Prüfen der Signaturen einen zeitkonstanten Vergleich (auch Constant-Time-Vergleich genannt). Das ist eine Sicherheits-Best-Practice, die Timing-Angriffe verhindert.

Ein Timing-Angriff misst, wie lange ein String-Vergleich dauert. Ein naiver ==-Vergleich gibt false zurück, sobald das erste abweichende Zeichen gefunden wird — ein Angreifer könnte so durch Messen der Antwortlatenz die Signatur Zeichen für Zeichen erraten.

Zeitkonstante Vergleichsfunktionen benötigen immer gleich viel Zeit, egal wie viele Zeichen übereinstimmen, und machen diesen Angriff damit praktisch unmöglich.

SpracheFunktion
Node.jscrypto.timingSafeEqual()
Pythonhmac.compare_digest()
Gohmac.Equal()
PHPhash_equals()
RubyRack::Utils.secure_compare()

Verwende niemals ===, == oder .equals(), um HMAC-Signaturen zu vergleichen. Nutze immer die eingebaute zeitkonstante Vergleichsfunktion deiner Sprache.

Was tun, wenn die Verifizierung fehlschlägt

Wenn die Signaturprüfung fehlschlägt, sollte dein Endpunkt:

  1. Den Statuscode 401 Unauthorized zurückgeben. Verarbeite die Payload nicht.
  2. Den Fehlschlag protokollieren — fürs Debugging. Nimm Anfrage-IP, Zeitstempel und (optional) die empfangene Signatur auf.
  3. Dein Secret niemals offenlegen — weder in Fehlermeldungen noch in Logs.
  4. Häufige Ursachen untersuchen:
    • Falsches Secret. Stelle sicher, dass du das aktuelle Webhook-Secret verwendest. Wenn du es kürzlich neu generiert hast, aktualisiere deinen Verifizierungscode.
    • Veränderter Body. Verifiziere gegen den rohen Anfrage-Body, nicht gegen eine geparste und neu serialisierte Version. Middleware, die JSON vor deinem Verifizierungscode parst, ist die häufigste Fehlerursache.
    • Encoding-Probleme. Der rohe Body muss als UTF-8-Bytes behandelt werden. Stelle sicher, dass dein Framework keine unerwarteten Encoding-Transformationen anwendet.
    • Veränderung durch Proxys. Wenn ein Reverse Proxy (z. B. Cloudflare, nginx) den Anfrage-Body verändert, stimmt die Signatur nicht. Konfiguriere deinen Proxy so, dass er den Body unverändert durchreicht.

Checkliste zur Fehlersuche

ProblemLösung
Signatur stimmt nie übereinPrüfe, ob du die rohen Body-Bytes liest und nicht ein geparstes JSON-Objekt
Signatur stimmt plötzlich nicht mehrPrüfe, ob dein Webhook-Secret neu generiert wurde
Signatur stimmt lokal, aber nicht in der ProduktionPrüfe auf Body-Veränderungen durch Proxy oder CDN
X-NueForm-Signature-Header fehltStelle sicher, dass dein Framework benutzerdefinierte Header erhält (manche entfernen Header mit X--Präfix)

Nächste Schritte

  • Testen --- Signaturprüfung lokal testen
  • Payloads --- Das Payload-Format verstehen, das du verifizierst
  • Ereignisse --- Die Ereignistypen kennenlernen
Zuletzt aktualisiert: 20. Juli 2026