11 min de cititInginerie

Verifică semnăturile webhook: HMAC în Node, Python, Go

Cum verifici semnătura unui webhook cu HMAC-SHA256: corp brut, comparare în timp constant, fereastră anti-replay și rotirea secretului, cu cod Node, Python.

Marius Voß
DevRel · edge infra

Ca să verifici semnătura unui webhook, recalculezi un HMAC peste exact octeții pe care i-a semnat expeditorul, cu secretul pe care îl împarți cu el, și compari valoarea ta cu antetul de semnătură în timp constant. Apoi verifici că marcajul de timp semnat este recent. Pentru webhook-urile Elido, asta înseamnă HMAC-SHA256 cu cheia ta whsec_... întreagă, peste {X-Webhook-Timestamp}.{raw body}, codificat hex, cu prefixul v1=, comparat cu X-Elido-Signature.

Acesta este întreg algoritmul. Eșecurile vin din detaliile din jurul lui: un parser de corp care a rulat prea devreme, un secret care a fost decodat, un rezumat hex comparat cu unul base64. Articolul acoperă verificarea semnăturii HMAC a webhook-urilor în general, apoi cod funcțional Elido pentru Node, Python, Go și un nod Code n8n, plus fereastra anti-replay și rotirea secretului, pe care majoritatea ghidurilor rapide le omit.

Dacă nu ai configurat încă un endpoint, începe cu webhook-uri pentru evenimente de link, care listează fiecare tip de eveniment și plicul payloadului. Pagina de față continuă din clipa în care o cerere semnată ajunge pe serverul tău.

Cum funcționează semnăturile HMAC ale webhook-urilor

Un endpoint de webhook este un URL public. Oricine îl găsește poate trimite prin POST un corp JSON care arată ca un eveniment real, deci receptorul are nevoie de o dovadă a originii. HMAC o oferă ieftin: expeditorul și receptorul împart un secret, expeditorul calculează HMAC-SHA256(secret, message) și pune rezultatul într-un antet, iar receptorul face același calcul și compară. Fără secret, nimeni nu poate produce o valoare potrivită, iar schimbarea unui singur octet din mesaj schimbă tot rezumatul.

Trei detalii diferă între furnizori, iar fiecare strică verificarea dacă îl greșești:

  1. Ce intră în mesaj. GitHub semnează doar corpul brut. Stripe și Elido semnează un marcaj de timp, un punct și corpul. Specificația Standard Webhooks semnează un ID de mesaj, marcajul de timp și corpul.
  2. Cum este codificat rezumatul. Hex sau base64, cu un prefix de schemă precum v1= sau sha256=.
  3. Care este cheia. Unii furnizori decodează secretul din base64 după prefixul lui. Elido nu: cheia este întregul șir whsec_, ca octeți UTF-8.

Contează că marcajul de timp se află în mesajul semnat. Împiedică un atacator să asocieze un corp vechi, semnat valid, cu un antet de marcaj de timp proaspăt, ceea ce face ca o fereastră anti-replay să poată fi impusă.

Cum verifici semnătura unui webhook: Elido unește marcajul de timp Unix, un punct și corpul brut, îl trece prin HMAC-SHA256 cu secretul whsec_ și trimite v1= hex în X-Elido-Signature; receptorul recalculează aceeași valoare din octeții bruți și din antetul marcajului de timp, apoi compară în timp constant Formularul Elido de adăugare a unui endpoint de webhook, cu URL-ul endpointului, selecția evenimentelor și secretul de semnare pe care receptorii îl folosesc pentru verificarea HMAC

Ce semnează Elido și ce antete îl poartă

Fiecare livrare către un endpoint event sau siem este un POST cu Content-Type: application/json și un corp de forma {"type", "workspace_id", "data", "timestamp"}. Tipurile de endpoint de tip chat (Discord, Telegram, Sentry) se autentifică prin URL și nu poartă antete HMAC, deci tot ce urmează se aplică doar primelor două tipuri.

AntetValoareCe faci cu el
X-Elido-Signaturev1= + 64 de caractere hex cu litere miciCompară-l cu valoarea calculată de tine
X-Webhook-SignatureAceeași valoare ca mai susAlias mai vechi; citește oricare, nu pe amândouă
X-Webhook-TimestampSecunde Unix, de exemplu 1789000000Face parte din mesajul semnat; verifică-i vârsta
X-Elido-Signature-Previousv1= + hex, semnat cu secretul vechiPrezent doar în fereastra de grație a unei rotiri
X-Webhook-EventNumele evenimentului, de exemplu link.createdDirecționează evenimentul (după verificare)
X-Webhook-DeliveryID numeric al livrării, stabil între reîncercăriCheie de deduplicare pentru procesare idempotentă

Primești secretul la creare: whsec_ urmat de 64 de caractere hex. Este returnat o singură dată în răspunsul de creare și niciodată după aceea, deci intră direct în depozitul tău de secrete.

Iată un vector de test pe care îți poți rula codul: cu secretul whsec_test_only_do_not_use, marcajul de timp 1789000000 și corpul {"type":"link.created","workspace_id":42}, valoarea corectă a antetului este:

v1=b9369aa411a8b7ce705bcd5bba112dea9d72d2e787aa88959ff62f33942d1a15

O poți reproduce dintr-un shell, ceea ce este prima mea mișcare ori de câte ori un receptor nu se înțelege cu expeditorul:

printf '%s.%s' 1789000000 '{"type":"link.created","workspace_id":42}' \
  | openssl dgst -sha256 -hmac 'whsec_test_only_do_not_use' -r

O capcană aici: câmpul timestamp din corp este momentul în care a avut loc evenimentul, în timp ce marcajul de timp semnat este cel din antetul X-Webhook-Timestamp, setat în momentul trimiterii cererii. Nu le confunda.

Validează semnătura unui webhook în Node

În Express, remedierea pentru majoritatea eșecurilor este o singură linie: montează express.raw() pe ruta webhook-ului, ca req.body să fie un Buffer cu exact octeții primiți. Înregistrează această rută înaintea oricărui app.use(express.json()) global, pentru că odată ce parserul JSON a consumat fluxul, parserul brut nu mai are ce citi.

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.ELIDO_WEBHOOK_SECRET; // the full whsec_... string
const TOLERANCE_SEC = 300;

function matches(expected, got) {
  const a = Buffer.from(expected);
  const b = Buffer.from(got ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}

const app = express();

app.post(
  "/webhooks/elido",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ts = req.get("X-Webhook-Timestamp") ?? "";
    if (
      !/^\d+$/.test(ts) ||
      Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SEC
    ) {
      return res.status(400).send("stale or missing timestamp");
    }
    const expected =
      "v1=" +
      createHmac("sha256", SECRET)
        .update(`${ts}.`)
        .update(req.body)
        .digest("hex");
    const ok = [
      req.get("X-Elido-Signature"),
      req.get("X-Elido-Signature-Previous"),
    ].some((got) => matches(expected, got));
    if (!ok) return res.status(401).send("bad signature");

    const event = JSON.parse(req.body.toString("utf8"));
    // enqueue event, then acknowledge fast
    res.sendStatus(200);
  },
);

Verificarea lungimii nu e decor. timingSafeEqual aruncă o eroare pentru buffere de lungimi diferite, în loc să returneze false. SDK-ul TypeScript din ghidul rapid API și SDK nu este încă pe npm, deci scrie comparația singur, ca mai sus.

Verificarea HMAC SHA256 a webhook-urilor în Python și Go

Biblioteca standard Python acoperă tot. Cu FastAPI, await request.body() returnează octeții bruți; în Flask, apelează request.get_data() înainte ca ceva să atingă request.json.

import hashlib
import hmac
import json
import os
import time

from fastapi import FastAPI, HTTPException, Request

SECRET = os.environ["ELIDO_WEBHOOK_SECRET"].strip().encode()
TOLERANCE = 300
app = FastAPI()


def verify(raw: bytes, ts: str, candidates: list) -> bool:
    if not ts.isdigit() or abs(time.time() - int(ts)) > TOLERANCE:
        return False
    digest = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    expected = "v1=" + digest
    return any(c and hmac.compare_digest(c, expected) for c in candidates)


@app.post("/webhooks/elido")
async def elido_webhook(request: Request):
    raw = await request.body()
    h = request.headers
    sigs = [h.get("x-elido-signature"), h.get("x-elido-signature-previous")]
    if not verify(raw, h.get("x-webhook-timestamp", ""), sigs):
        raise HTTPException(status_code=401, detail="bad signature")
    event = json.loads(raw)
    # enqueue event
    return {"ok": True}

În Go, citește corpul o singură dată, limitează-i dimensiunea și folosește hmac.Equal din crypto/hmac, care compară în timp constant. Construiesc mesajul din șirul antetului exact cum a fost primit, în loc să reformatez întregul parsat.

package webhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"strconv"
	"time"
)

const toleranceSec = 300

// Verify returns the raw body when the request carries a valid Elido signature.
func Verify(w http.ResponseWriter, r *http.Request, secret []byte) ([]byte, bool) {
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		return nil, false
	}
	tsHeader := r.Header.Get("X-Webhook-Timestamp")
	ts, err := strconv.ParseInt(tsHeader, 10, 64)
	if err != nil {
		return nil, false
	}
	if age := time.Now().Unix() - ts; age > toleranceSec || age < -toleranceSec {
		return nil, false
	}

	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(tsHeader + "."))
	mac.Write(body)
	expected := []byte("v1=" + hex.EncodeToString(mac.Sum(nil)))

	for _, name := range []string{"X-Elido-Signature", "X-Elido-Signature-Previous"} {
		if got := r.Header.Get(name); got != "" && hmac.Equal([]byte(got), expected) {
			return body, true
		}
	}
	return nil, false
}

Toate cele trei versiuni răspund cu 401 la o semnătură greșită și parsează JSON-ul abia după ce verificarea trece. Elido socotește orice non-2xx drept încercare eșuată, deci dacă verificatorul tău e greșit, livrările autentice se adună ca 401 în jurnalul de livrări al endpointului. Acolo te uiți primul după un deploy.

Vrei să vezi asta pe trafic real? Creează un endpoint într-un workspace de pe pagina funcției webhook-uri și îndreaptă-l spre un tunel local; jurnalul de livrări arată codul de stare pe care l-a returnat verificatorul tău pentru fiecare încercare.

Verifică semnătura webhook-ului într-un nod Code din n8n

n8n poate face aceeași verificare fără niciun serviciu în plus. Activează opțiunea Raw Body în nodul Webhook, care stochează cererea neatinsă ca date binare, apoi adaugă un nod Code imediat după el. Modulul crypto încorporat este permis în nodul Code din n8n, deci acesta rulează ca atare:

const crypto = require("crypto");
const h = $input.first().json.headers;
const raw = await this.helpers.getBinaryDataBuffer(0, "data");

const ts = h["x-webhook-timestamp"] ?? "";
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
  throw new Error("stale delivery");
}
const expected = Buffer.from(
  "v1=" +
    crypto
      .createHmac("sha256", $env.ELIDO_WEBHOOK_SECRET)
      .update(`${ts}.`)
      .update(raw)
      .digest("hex"),
);
const ok = ["x-elido-signature", "x-elido-signature-previous"].some((name) => {
  const got = Buffer.from(h[name] ?? "");
  return (
    got.length === expected.length && crypto.timingSafeEqual(got, expected)
  );
});
if (!ok) throw new Error("bad signature");
return [{ json: JSON.parse(raw.toString("utf8")) }];

O eroare aruncată oprește execuția, deci nimic din aval nu rulează pe un eveniment falsificat. O capcană la self-hosting: dacă instanța ta setează N8N_BLOCK_ENV_ACCESS_IN_NODE=true, nodul Code nu poate citi $env, secretul vine gol, iar fiecare livrare pică verificarea. Ghidul n8n self-hosted acoperă partea de reverse proxy, iar articolul despre nodul n8n pentru scurtătorul de URL arată ce poți construi după ce sosesc evenimentele.

Cum diferă Elido de Stripe, GitHub și Standard Webhooks

Dacă ai scris un verificator pentru alt furnizor, nu-l copia orbește. Verificat în documentația fiecărui furnizor la 2026-10-25:

  • Stripe pune t= și una sau mai multe valori v1= într-un singur antet Stripe-Signature, semnează timestamp.body cu HMAC-SHA256 în hex, iar bibliotecile lui folosesc implicit o toleranță de cinci minute (documentația Stripe). Elido separă marcajul de timp în propriul antet.
  • GitHub semnează doar corpul brut, trimite sha256= plus hex în X-Hub-Signature-256 și îți spune să folosești o comparare în timp constant (documentația GitHub). Nu există un marcaj de timp de verificat.
  • Standard Webhooks folosește webhook-id, webhook-timestamp și webhook-signature, semnează id.timestamp.body, codifică în base64 cu prefixul v1, și lasă valoarea toleranței în seama ta (specificația). X-Webhook-Delivery de la Elido joacă rolul ID-ului, dar nu face parte din mesajul semnat.

Schema Elido este cea din signing.Sign din api-core: mesajul este marcajul de timp Unix, un punct și corpul, cheia este șirul secretului așa cum este stocat, iar rezultatul este v1= plus hex cu litere mici. Elido nu are un eveniment link.clicked, deci nu te aștepta la livrări la nivel de clic pe acest canal.

Ferestre anti-replay și rotirea secretului

O semnătură validă dovedește cine a trimis o cerere, nu și când. Cineva care captează o livrare semnată, dintr-o linie de log sau de la un proxy configurat greșit, ar putea s-o trimită din nou peste o săptămână, iar HMAC-ul tot s-ar potrivi. Verificarea marcajului de timp închide această breșă, și e treaba ta: workerul de livrare Elido semnează marcajul de timp, dar nu impune nicio fereastră din partea ta. Eu folosesc 300 de secunde. Ține ceasul receptorului sincronizat cu NTP, pentru că un server care derivă cu câteva minute va începe să respingă trafic cinstit.

Reîncercările nu încalcă fereastra. Fiecare încercare primește un X-Webhook-Timestamp proaspăt și o semnătură nouă, în timp ce X-Webhook-Delivery rămâne același. Această împărțire îți dă ambele apărări: marcajul de timp limitează cât timp rămâne utilizabilă o cerere captată, iar un index unic pe ID-ul livrării împiedică procesarea de două ori a unei reîncercări legitime. Articolul despre limite de rată și idempotență acoperă același tipar pe partea de intrare a API-ului.

Rotirea merge prin POST /v1/workspaces/{workspace_id}/webhooks/{id}/rotate-secret sau prin butonul Rotate de pe pagina endpointului. Răspunsul conține o singură dată noul secret, plus grace_window_days (7) și previous_expires_at. Timp de aceste șapte zile, fiecare livrare poartă două semnături:

  • X-Elido-Signature, făcută cu secretul nou
  • X-Elido-Signature-Previous, făcută cu cel vechi

De aceea fiecare fragment de mai sus verifică ambele antete cu singurul secret pe care îl deține. Un receptor care încă rulează cu secretul vechi se potrivește cu al doilea antet; după ce îl implementezi pe cel nou, se potrivește cu primul. Nimic nu eșuează între timp. Tratează totuși antetul cheii anterioare ca pe o punte, nu ca pe o garanție, și livrează secretul nou devreme în săptămână.

Cronologia rotirii secretului unui webhook: înainte de rotire se trimite doar X-Elido-Signature; după rotate-secret, pe o fereastră de grație de șapte zile, livrările poartă X-Elido-Signature cu cheia nouă și X-Elido-Signature-Previous cu cheia veche, deci un receptor care deține oricare secret verifică.

De ce eșuează verificarea semnăturii webhook

Când ajut pe cineva să depaneze asta, aproape de fiecare dată e aceeași listă scurtă. Parcurge-o în ordine:

  1. JSON reserializat. JSON.stringify(req.body) sau json.dumps(payload) produce alți octeți decât cei pe care i-a hash-uit expeditorul: ordinea cheilor, spațierea, barele escapate, escape-urile unicode. Calculează hash-ul pe corpul brut. Dacă framework-ul l-a parsat deja, repară ordinea middleware-ului în loc să încerci să reconstruiești șirul.
  2. Octeții greșiți ai cheii. Elido folosește întregul șir whsec_... drept cheie HMAC. Eliminarea prefixului, decodarea hex a restului sau decodarea base64 (ceea ce fac bibliotecile Standard Webhooks) îți dă o cheie diferită. Un newline final de la echo într-un fișier de secrete face același lucru, motiv pentru care exemplul Python apelează .strip().
  3. Nepotrivire de codificare. Compară v1= plus hex cu litere mici cu antetul. Un rezumat base64, un șir hex cu litere mari sau un prefix lipsă nu se potrivesc niciodată.
  4. Marcajul de timp greșit. Folosește șirul antetului X-Webhook-Timestamp, nu câmpul timestamp din corp și nu un număr pe care l-ai parsat și reformatat.

Încă două, mai mici: compararea cu == funcționează, dar scurge informații de timp, deci folosește funcția de timp constant pe care o oferă limbajul tău; iar un proxy care decomprimă sau recodifică corpurile va strica și el verificarea, deși e rar pentru POST-uri JSON simple.

Când cele două părți tot nu se înțeleg, înregistrează în log marcajul de timp, lungimea corpului și primele caractere hex ale rezumatului tău, apoi rulează linia openssl de mai devreme pe aceleași intrări. Partea care se potrivește cu openssl este cea corectă. Pentru locul semnării între celelalte controale care merită verificate la orice furnizor, vezi lista de verificare pentru securitatea scurtătoarelor de URL.

Citește articolul-pilon: webhook-uri pentru evenimente de link.

Pe același subiect pe blog

Întrebări frecvente

Cum verific semnătura unui webhook?

Recalculează HMAC peste exact ce a semnat expeditorul, folosind secretul comun, și compară rezultatul cu antetul de semnătură în timp constant. La Elido înseamnă HMAC-SHA256 peste valoarea X-Webhook-Timestamp, un punct și corpul brut, codificat hex, cu prefixul v1=. Respinge cererea dacă nu se potrivește nimic sau dacă marcajul de timp este vechi.

De ce eșuează mereu verificarea semnăturii webhook?

Aproape întotdeauna pentru că ai calculat hash-ul pe alți octeți decât expeditorul. Un parser de JSON a rulat primul, iar tu ai reserializat obiectul, ai decodat secretul sau ai comparat hex cu base64. Calculează hash-ul pe octeții bruți ai cererii, folosește șirul secretului exact cum a fost emis și afișează ambele valori una lângă alta în log.

Ce este HMAC într-un webhook?

HMAC este un hash cu cheie: expeditorul amestecă un secret pe care îl împarte cu tine într-un hash SHA-256 al mesajului. Doar cine deține secretul poate produce o valoare potrivită, deci o semnătură validă dovedește că cererea vine de la expeditor și că nu s-a schimbat corpul pe drum.

Cum previn atacurile replay asupra webhook-urilor?

Semnează marcajul de timp împreună cu corpul și respinge orice cerere al cărei marcaj de timp are mai mult de câteva minute; cinci minute este o fereastră frecventă. Apoi stochează ID-ul livrării și sari peste ID-urile pe care le-ai procesat deja. Elido semnează marcajul de timp, dar lasă verificarea prospețimii în sarcina receptorului tău.

Folosesc hex sau base64 pentru semnătura HMAC-SHA256 a unui webhook?

Ce documentează expeditorul, pentru că cele două codificări ale aceluiași rezumat nu se compară niciodată ca egale. Elido trimite hex cu litere mici după prefixul v1=. Shopify și specificația Standard Webhooks folosesc base64, iar GitHub folosește hex după sha256=. Codifică rezumatul tău la fel înainte să compari.

Cum rotesc secretul unui webhook fără să pierd evenimente?

Folosește un expeditor care semnează un timp cu ambele chei. După ce rotești secretul unui endpoint Elido, fiecare livrare poartă X-Elido-Signature cu cheia nouă și X-Elido-Signature-Previous cu cea veche, timp de șapte zile. Acceptă oricare antet, implementează noul secret, iar cel vechi expiră de la sine.

Încearcă Elido

Lipește un URL, obții un link scurt funcțional

Fără înregistrare. Linkul este activ timp de 30 de zile. Înregistrează-te ca să-l păstrezi pentru totdeauna.

Fără înregistrare. Creează un cont gratuit pentru 50 linkuri pe lună.

Încearcă Elido

Scurtător de URL-uri găzduit în UE, cu domenii personalizate, analiză avansată și un API deschis. Nivel gratuit - fără card bancar.

Etichete
verify webhook signature
webhook signature verification
hmac webhook
webhook hmac sha256
webhook replay attack
webhook secret rotation

Continuă lectura