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:
- 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.
- Cum este codificat rezumatul. Hex sau base64, cu un prefix de schemă precum
v1=sausha256=. - 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ă.
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.
| Antet | Valoare | Ce faci cu el |
|---|---|---|
X-Elido-Signature | v1= + 64 de caractere hex cu litere mici | Compară-l cu valoarea calculată de tine |
X-Webhook-Signature | Aceeași valoare ca mai sus | Alias mai vechi; citește oricare, nu pe amândouă |
X-Webhook-Timestamp | Secunde Unix, de exemplu 1789000000 | Face parte din mesajul semnat; verifică-i vârsta |
X-Elido-Signature-Previous | v1= + hex, semnat cu secretul vechi | Prezent doar în fereastra de grație a unei rotiri |
X-Webhook-Event | Numele evenimentului, de exemplu link.created | Direcționează evenimentul (după verificare) |
X-Webhook-Delivery | ID numeric al livrării, stabil între reîncercări | Cheie 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 valoriv1=într-un singur antetStripe-Signature, semneazătimestamp.bodycu 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 înX-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șiwebhook-signature, semneazăid.timestamp.body, codifică în base64 cu prefixulv1,și lasă valoarea toleranței în seama ta (specificația).X-Webhook-Deliveryde 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 nouX-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ă.
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:
- JSON reserializat.
JSON.stringify(req.body)saujson.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. - 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 laechoîntr-un fișier de secrete face același lucru, motiv pentru care exemplul Python apelează.strip(). - 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ă. - Marcajul de timp greșit. Folosește șirul antetului
X-Webhook-Timestamp, nu câmpultimestampdin 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
- Webhook-uri pentru evenimente de link - tipuri de evenimente, plicul payloadului și politica de retry.
- Webhook-uri vs polling pentru urmărirea clicurilor - când împingerea bate tragerea și când nu.
- Automatizarea linkurilor self-hosted cu n8n - reverse proxy, mod coadă și verificări de semnătură într-un singur stack.
- API-ul scurtătorului de URL: limite de rată, reîncercări, idempotență - tiparele de deduplicare văzute din cealaltă direcție.
- Lista de verificare pentru securitatea scurtătoarelor de URL - nouă controale de verificat la orice furnizor.
Î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ă.