Weryfikacja podpisu
Nagłówki dostarczenia, wzór HMAC-SHA256 i gotowy kod odbiornika w TypeScript i PHP.
Każde dostarczenie jest podpisane sekretem Twojego endpointu. Bez weryfikacji podpisu każdy, kto zna adres, może podrzucić Ci fałszywe zdarzenie — weryfikuj zawsze, także w środowisku testowym.
Nagłówki
| Nagłówek | Znaczenie |
|---|---|
Content-Type | Zawsze application/json. |
Mediflo-Event-Id | Identyfikator koperty powtórzony w nagłówku, żeby dało się odsiać duplikat bez parsowania ciała. To Twój klucz idempotencji — dostarczamy co najmniej raz. |
Mediflo-Event-Type | Typ zdarzenia powtórzony w nagłówku, żeby dało się routować albo odrzucić nieznany typ bez parsowania ciała. |
Mediflo-Signature | t=<sekundy-unix>,v1=<hex>. Podczas rotacji sekretu są dwa wpisy v1=. |
Mediflo-Event-Test | Występuje z wartością true tylko wtedy, gdy sam wyślesz zdarzenie testowe. Przy prawdziwym zdarzeniu nagłówka nie ma (nie ma też wartości false). |
Wzór
v1 = HMAC_SHA256(sekret, "<t>.<surowe ciało>") → zapis szesnastkowyZnacznik czasu t jest częścią podpisywanego ciągu — to on chroni przed powtórzeniem. Odrzucaj żądania, których t odbiega od Twojego zegara o więcej niż ±5 minut (300 s).
Podpisuj SUROWE bajty
Ponowna serializacja sparsowanego JSON-a nie odtworzy tego samego skrótu. Czytaj ciało jako bajty, zanim cokolwiek je sparsuje. Sprawdź też poprawność zapisu szesnastkowego zanim porównasz — w Node timingSafeEqual rzuca wyjątkiem przy różnej długości, więc bez tego zabezpieczenia zniekształcony nagłówek zwróci 500 zamiast odrzucenia.
Rotacja sekretu
Gdy wygenerujesz nowy sekret, przez 24 godziny każde żądanie ma dwa wpisy v1= — najpierw dla nowego sekretu, potem dla poprzedniego. Zaakceptuj żądanie, jeśli pasuje którykolwiek; nie przerywaj na pierwszym, który się nie zgadza. Pole previousSecretExpiresAt na endpoincie mówi, kiedy stary sekret przestaje działać.
Gotowy kod
Poniższe fragmenty to dokładnie te same funkcje, które nasz zestaw testów uruchamia przeciwko prawdziwemu modułowi podpisującemu. Wklej je zamiast pisać własne sprawdzenie HMAC z opisu.
import { createHmac, timingSafeEqual } from 'node:crypto'/** * Verify a Mediflo webhook signature. * * Header shape: `t=<unix-seconds>,v1=<hex>[,v1=<hex>]` * where `v1 = HMAC_SHA256(secret, "<t>.<rawBody>")`. * * `rawBody` MUST be the exact bytes we sent. Re-serializing parsed JSON does * not reproduce the digest — in Express, use * `express.json({ verify: (req, _res, buf) => { req.rawBody = buf } })`. * * Pass BOTH secrets during a rotation window (or just the current one; the * header carries a `v1=` entry per active secret and any match is valid). */export function verifyMedifloSignature( header: string | undefined, rawBody: string | Buffer, secrets: string[], toleranceSeconds = 300): boolean { if (!header || secrets.length === 0) return false let timestamp: number | null = null const presented: string[] = [] for (const part of header.split(',')) { const trimmed = part.trim() const eq = trimmed.indexOf('=') if (eq <= 0) continue const key = trimmed.slice(0, eq) const value = trimmed.slice(eq + 1) if (key === 't') { if (!/^\d+$/.test(value)) return false timestamp = Number(value) } else if (key === 'v1') { // More than one entry appears during a 24 h secret rotation. presented.push(value) } } if (timestamp === null || presented.length === 0) return false // Replay protection: the timestamp is INSIDE the signed string, so an // attacker cannot move it forward without invalidating every digest. const now = Math.floor(Date.now() / 1000) if (Math.abs(now - timestamp) > toleranceSeconds) return false for (const secret of secrets) { const expected = createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex') for (const candidate of presented) { // Validate the hex BEFORE comparing. Buffer.from('zz', 'hex') truncates // silently, and timingSafeEqual throws RangeError on a length mismatch — // so without these two guards a malformed header crashes the handler // instead of returning false. const normalized = candidate.trim().toLowerCase() if (!/^[0-9a-f]+$/.test(normalized)) continue if (normalized.length !== expected.length) continue if ( timingSafeEqual( Buffer.from(normalized, 'hex'), Buffer.from(expected, 'hex') ) ) { return true } } } return false}PHP jest tu nie bez powodu: integracje po stronie polskich firm najczęściej pisze ten sam zespół, który utrzymuje stronę i panel klienta na hostingu PHP (Laravel, Symfony, WordPress) — i to tam ląduje odbiornik webhooków.
Higiena sekretu
- Sekret pokazujemy raz: przy tworzeniu webhooka i przy każdej rotacji. Jest zaszyfrowany i nie ma endpointu, który by go zwrócił.
- Zgubiony sekret = wygeneruj nowy (przycisk „Nowy sekret" w aplikacji albo
POST /webhook-endpoints/{endpointId}/rotate-secret). To jedyna droga odzyskania. - Nigdy nie umieszczaj sekretu w adresie URL, w kodzie przeglądarki ani w logach.