Assinatura
Seu endpoint de webhook é uma URL pública: qualquer um pode fazer POST nela. A assinatura é o que
separa um evento nosso de um transaction.paid forjado por alguém que descobriu o seu endereço.
Verifique sempre, inclusive no ambiente de teste, senão você descobre que esqueceu justamente
quando for para produção.
O header
Toda entrega traz:
X-Signature: t=1789041871,v1=5f1e9c...
té o instante em que a entrega foi montada, em segundos desde a época Unix.v1é o HMAC-SHA256, em hexadecimal minúsculo, da stringt+.+ corpo cru, usando o segredo do endpoint (whsec_…) como chave.- Pode vir mais de um
v1=. Ver "Rotação" abaixo.
Em pseudocódigo, o que nós calculamos:
assinatura = hex(hmac_sha256(chave = segredo, mensagem = t + "." + corpo_cru))
O corpo cru é o corpo cru
O corpo_cru são os bytes exatos que chegaram na requisição, na ordem em que chegaram. Não é o
objeto desserializado, não é o objeto reserializado, não é o JSON reformatado pelo seu framework.
Reserializar troca a ordem das chaves ou o espaçamento, muda um byte e o HMAC não fecha.
Esse é o erro que mais aparece, e ele depende do framework:
- Express: monte a rota com
express.raw({ type: 'application/json' }).express.json()descarta os bytes originais. - Fastify: registre um
addContentTypeParserque guarde o buffer. - Flask:
request.get_data()antes de tocar emrequest.json. - Django:
request.body. - Laravel:
$request->getContent(), nunca$request->all(). - PHP puro:
file_get_contents('php://input').
Detalhes por stack Node em verificar em Node.
Janela de tempo
Recuse a entrega se |agora - t| > 300 segundos. Sem essa checagem, uma requisição nossa capturada
hoje pode ser reenviada por um atacante daqui a um mês com a assinatura ainda válida.
São 300 s para os dois lados, não só para o passado: relógio adiantado no seu servidor também derruba a verificação. Se você começar a ver rejeições por tempo em massa, olhe o NTP da sua máquina antes de suspeitar da assinatura.
Comparação em tempo constante
Compare o hexadecimal esperado com o recebido usando a função de tempo constante da sua linguagem
(crypto.timingSafeEqual, hmac.compare_digest, hash_equals), nunca ==. Comparação normal
para no primeiro byte diferente, e a diferença de tempo entre "errou no primeiro byte" e "errou no
trigésimo" é medível pela rede: dá para reconstruir a assinatura byte a byte.
Duas armadilhas na hora de usar essas funções:
timingSafeEqualdo Node lança exceção se os buffers tiverem tamanhos diferentes. Compare o comprimento antes, e compare os hexadecimais como bytes ASCII, sem decodificar (hex inválido seria truncado em silêncio).- Não saia do laço no primeiro acerto quando houver várias assinaturas. Parar cedo devolve pelo tempo qual delas bateu.
Rotação: duas assinaturas ao mesmo tempo
Ao rotacionar o segredo você recebe o novo e um previous_secret_expires_at 24 h à frente. Durante
essa janela toda entrega sai com dois v1=, o primeiro com o segredo novo e o segundo com o
antigo:
X-Signature: t=1789041871,v1=<assinatura com o segredo novo>,v1=<assinatura com o antigo>
A verificação certa é: aceite se qualquer v1= bater com qualquer segredo que você conhece.
Assim o seu receptor continua funcionando com o segredo antigo enquanto você faz o deploy do novo,
e volta a ter um só depois. Quem verifica apenas o primeiro v1= quebra na rotação; quem verifica
o header inteiro como se fosse uma string única nunca funciona.
Passadas as 24 h o segredo antigo é apagado e as entregas voltam a ter um v1= só.
Verificação pronta
Estes arquivos são executáveis e não têm dependência nenhuma. Cada um roda o próprio autoteste:
assina um payload fixo localmente e confere corpo adulterado, t fora da janela, header malformado
e o caso das duas assinaturas da rotação.
Em Node, o exemplo completo (handler cru e middleware de Express) está em verificar em Node.
Python
python3 verify-webhook.py roda o autoteste.
#!/usr/bin/env python3
"""Verificação da assinatura X-Signature de um webhook da LeztyPay, em Python 3.
Zero dependência: só a biblioteca padrão. Rode `python3 verify-webhook.py` para o
autoteste (assina um payload fixo localmente, sem rede).
O que é assinado é `<t>.<bytes crus do corpo>`. Pegue o corpo antes de qualquer parser
de JSON, senão o hash não fecha:
Flask raw = request.get_data(); header = request.headers.get('X-Signature')
Django raw = request.body; header = request.META.get('HTTP_X_SIGNATURE')
FastAPI raw = await request.body(); header = request.headers.get('x-signature')
"""
import hashlib
import hmac
import json
import os
import time
# Janela aceita para o `t=` do header, em segundos.
TOLERANCE_SECONDS = 300
# Durante as 24 h de graça da rotação mantenha os dois: o emissor manda
# `v1=<novo>,v1=<antigo>` e basta um bater.
SECRETS = [
value
for value in (
os.environ.get("LM_WEBHOOK_SECRET"),
os.environ.get("LM_WEBHOOK_SECRET_PREVIOUS"),
)
if value
]
def verify_signature(header, raw_body, secrets, now_sec=None, tolerance_sec=TOLERANCE_SECONDS):
"""Devolve (True, 'ok') ou (False, 'malformed' | 'expired' | 'mismatch')."""
now_sec = int(time.time()) if now_sec is None else now_sec
timestamp = None
given = []
for part in (header or "").split(","):
piece = part.strip()
if piece.startswith("t="):
try:
timestamp = int(piece[2:])
except ValueError:
return False, "malformed"
elif piece.startswith("v1="):
given.append(piece[3:])
if timestamp is None or not given:
return False, "malformed"
if abs(now_sec - timestamp) > tolerance_sec:
return False, "expired"
body = raw_body if isinstance(raw_body, bytes) else raw_body.encode("utf-8")
signed = f"{timestamp}.".encode("utf-8") + body
matched = False
for secret in secrets:
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
for candidate in given:
# compare_digest é comparação em tempo constante e aceita tamanhos diferentes.
# Sem `break`: avaliar todas as combinações evita vazar por tempo qual bateu.
if hmac.compare_digest(candidate, expected):
matched = True
return (True, "ok") if matched else (False, "mismatch")
def handle_request(header, raw_body, on_event, secrets=None):
"""Devolve o status HTTP que o seu receptor deve responder.
Responda 2xx em menos de 10 s: `on_event` deve só enfileirar o evento. Exceção não
tratada vira 500 e a entrega é retentada.
"""
ok, reason = verify_signature(header, raw_body, SECRETS if secrets is None else secrets)
if not ok:
return 401, reason
on_event(json.loads(raw_body))
return 200, "ok"
def _self_test():
secret = "whsec_exemploDeSecretDeEndpoint"
raw_body = '{"id":"evt_1","object":"event","type":"transaction.paid"}'
t = 1789041871
def sign(key):
signed = f"{t}.{raw_body}".encode("utf-8")
return hmac.new(key.encode("utf-8"), signed, hashlib.sha256).hexdigest()
header = f"t={t},v1={sign(secret)}"
assert verify_signature(header, raw_body, [secret], now_sec=t) == (True, "ok")
# Um byte a mais no corpo já derruba o hash.
assert verify_signature(header, raw_body + " ", [secret], now_sec=t)[1] == "mismatch"
# Fora da janela de 300 s, mesmo com assinatura boa.
assert verify_signature(header, raw_body, [secret], now_sec=t + 301)[1] == "expired"
assert verify_signature(f"v1={sign(secret)}", raw_body, [secret], now_sec=t)[1] == "malformed"
assert verify_signature(f"t={t}", raw_body, [secret], now_sec=t)[1] == "malformed"
# Rotação: chegam duas assinaturas e o receptor só conhece a antiga.
rotacao = f"t={t},v1={sign('whsec_secretNovoQueVoceAindaNaoGuardou')},v1={sign(secret)}"
assert verify_signature(rotacao, raw_body, [secret], now_sec=t) == (True, "ok")
# Bytes ou str dão o mesmo resultado.
assert verify_signature(header, raw_body.encode("utf-8"), [secret], now_sec=t) == (True, "ok")
print("verify-webhook: 7 asserções ok")
if __name__ == "__main__":
_self_test()
PHP
php verify-webhook.php roda o autoteste.
<?php
// Verificação da assinatura X-Signature de um webhook da LeztyPay, em PHP 8.
//
// Zero dependência: só funções nativas. Rode `php verify-webhook.php` para o autoteste
// (assina um payload fixo localmente, sem rede).
//
// O que é assinado é `<t>.<bytes crus do corpo>`. Em PHP o corpo cru é `php://input` e o
// header chega em `$_SERVER['HTTP_X_SIGNATURE']`. Em Laravel use `$request->getContent()`
// e `$request->header('X-Signature')`; nunca `$request->all()`, que já perdeu os bytes.
declare(strict_types=1);
/** Janela aceita para o `t=` do header, em segundos. */
const LEZTYPAY_TOLERANCE_SECONDS = 300;
/**
* @param string[] $secrets Durante as 24 h de graça da rotação mantenha os dois: o
* emissor manda `v1=<novo>,v1=<antigo>` e basta um bater.
* @return string 'ok' | 'malformed' | 'expired' | 'mismatch'
*/
function leztypay_verify_signature(
?string $header,
string $rawBody,
array $secrets,
?int $nowSec = null,
int $toleranceSec = LEZTYPAY_TOLERANCE_SECONDS
): string {
$nowSec ??= time();
$timestamp = null;
$given = [];
foreach (explode(',', (string) $header) as $part) {
$piece = trim($part);
if (str_starts_with($piece, 't=')) {
$value = substr($piece, 2);
if ($value === '' || !ctype_digit($value)) {
return 'malformed';
}
$timestamp = (int) $value;
} elseif (str_starts_with($piece, 'v1=')) {
$given[] = substr($piece, 3);
}
}
if ($timestamp === null || $given === []) {
return 'malformed';
}
if (abs($nowSec - $timestamp) > $toleranceSec) {
return 'expired';
}
$matched = false;
foreach ($secrets as $secret) {
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach ($given as $candidate) {
// hash_equals é comparação em tempo constante. Sem `break`: avaliar todas as
// combinações evita vazar por tempo qual assinatura bateu.
if (hash_equals($expected, $candidate)) {
$matched = true;
}
}
}
return $matched ? 'ok' : 'mismatch';
}
/** @return string[] */
function leztypay_secrets(): array
{
$secrets = [getenv('LM_WEBHOOK_SECRET'), getenv('LM_WEBHOOK_SECRET_PREVIOUS')];
return array_values(array_filter($secrets, static fn ($secret) => is_string($secret) && $secret !== ''));
}
/**
* Receptor cru. Responda 2xx em menos de 10 s: `$onEvent` deve só enfileirar o evento.
*/
function leztypay_handle_request(callable $onEvent): void
{
$rawBody = (string) file_get_contents('php://input');
$header = $_SERVER['HTTP_X_SIGNATURE'] ?? null;
$result = leztypay_verify_signature($header, $rawBody, leztypay_secrets());
if ($result !== 'ok') {
http_response_code(401);
echo $result;
return;
}
$onEvent(json_decode($rawBody, true), $_SERVER['HTTP_X_EVENT_ID'] ?? null);
http_response_code(200);
}
function leztypay_self_test(): void
{
$secret = 'whsec_exemploDeSecretDeEndpoint';
$rawBody = '{"id":"evt_1","object":"event","type":"transaction.paid"}';
$t = 1789041871;
$sign = static fn (string $key): string => hash_hmac('sha256', $t . '.' . $rawBody, $key);
$header = 't=' . $t . ',v1=' . $sign($secret);
$cases = [
['ok', leztypay_verify_signature($header, $rawBody, [$secret], $t)],
// Um byte a mais no corpo já derruba o hash.
['mismatch', leztypay_verify_signature($header, $rawBody . ' ', [$secret], $t)],
// Fora da janela de 300 s, mesmo com assinatura boa.
['expired', leztypay_verify_signature($header, $rawBody, [$secret], $t + 301)],
['malformed', leztypay_verify_signature('v1=' . $sign($secret), $rawBody, [$secret], $t)],
['malformed', leztypay_verify_signature('t=' . $t, $rawBody, [$secret], $t)],
// Rotação: chegam duas assinaturas e o receptor só conhece a antiga.
['ok', leztypay_verify_signature(
't=' . $t . ',v1=' . $sign('whsec_secretNovoQueVoceAindaNaoGuardou') . ',v1=' . $sign($secret),
$rawBody,
[$secret],
$t
)],
];
foreach ($cases as $index => [$esperado, $obtido]) {
if ($esperado !== $obtido) {
fwrite(STDERR, "caso {$index}: esperava {$esperado}, veio {$obtido}\n");
exit(1);
}
}
echo 'verify-webhook: ' . count($cases) . " asserções ok\n";
}
if (PHP_SAPI === 'cli') {
leztypay_self_test();
}
Depois que a assinatura bate
Verificar a assinatura prova a origem e a integridade, não a unicidade: o mesmo evento assinado corretamente pode chegar duas vezes. A deduplicação é um passo à parte, em idempotência e ordem.