hmac em Python: assinar webhooks e mensagens com chave secreta

Aprenda hmac em Python para assinar webhooks, comparar digests com segurança, montar headers de API e evitar erros comuns de encoding, timing e chave fraca.

9 min de leitura Equipe Python Brasil

O módulo hmac é a peça da biblioteca padrão para autenticar mensagens com uma chave secreta em Python. Em português claro: ele responde se este corpo de webhook (ou header de API) ainda é o mesmo que o emissor assinou — e se o emissor conhece o segredo compartilhado.

A recomendação direta é: assine com hmac.new(chave, mensagem, hashlib.sha256); envie o digest em hex ou Base64 conforme o contrato da API; valide sempre com hmac.compare_digest(); trate chave e corpo como bytes; e nunca confunda HMAC com criptografia. Para checksums sem chave, use hashlib; para tokens aleatórios, use secrets; para transportar o digest em texto, use base64.

Neste guia, você vai assinar e verificar mensagens, montar um validador de webhook reutilizável, evitar ataques de timing e encoding, escolher o algoritmo certo e encaixar HMAC em fluxos reais de backend brasileiro — de PSP de pagamento a filas internas e automações.

O problema que o HMAC resolve

Imagine um endpoint que recebe eventos de um provedor:

POST /webhooks/pagamentos HTTP/1.1
Content-Type: application/json
X-Signature: 3f1c...

{"pedido":"1042","status":"pago","valor":199.90}

Se o endpoint confiar no JSON sem autenticação, qualquer pessoa na internet pode forjar um "status":"pago". Um hash SHA-256 do corpo sem chave também não basta: o atacante recalcula o hash do corpo adulterado e envia os dois juntos.

HMAC resolve isso com um segredo que só o emissor e o receptor conhecem. A assinatura depende do corpo e da chave. Sem a chave, forjar um digest válido fica computacionalmente inviável para algoritmos modernos.

import hashlib
import hmac

chave = b"segredo-compartilhado-do-provedor"
corpo = b'{"pedido":"1042","status":"pago"}'

assinatura = hmac.new(chave, corpo, hashlib.sha256).hexdigest()
print(assinatura)

Quem recebe o webhook recalcula o mesmo valor e compara de forma segura. Se bater, o corpo não foi alterado no caminho e quem assinou tinha a chave.

hashlib vs hmac: quando usar cada um

NecessidadeFerramentaExemplo
Fingerprint de arquivo ou conteúdohashlibSHA-256 de um PDF baixado
Detectar alteração sem autenticar origemhashlibdeduplicar blobs no storage
Validar webhook / API com segredo compartilhadohmacStripe, GitHub, PSP, CRM
Token opaco aleatóriosecretsreset de senha, API key
Transportar bytes em JSON/URLbase64embutir digest ou certificado

Regra prática:

  1. só integridade do conteúdohashlib;
  2. integridade + origem com chave simétricahmac;
  3. identidade de usuário / OAuth / chaves assimétricas → bibliotecas de JWT, OpenID ou assinatura RSA/ECDSA — HMAC sozinho não substitui isso.

O post de hashlib mostra o checksum e menciona HMAC de passagem. Aqui o foco é o contrato completo de assinatura.

API essencial do módulo hmac

Criar e ler o digest

import hashlib
import hmac

chave = b"chave-secreta"
mensagem = b"corpo-exato-dos-bytes-recebidos"

digest = hmac.new(chave, mensagem, hashlib.sha256)

print(digest.hexdigest())   # string hex de 64 chars
print(digest.digest())      # 32 bytes brutos
print(digest.name)          # 'hmac-sha256' em builds recentes / dependente da versão

Você também pode alimentar a mensagem em partes — útil para streams e arquivos grandes:

import hashlib
import hmac

h = hmac.new(b"chave", digestmod=hashlib.sha256)
h.update(b'{"pedido":')
h.update(b'"1042"}')
print(h.hexdigest())

O resultado é idêntico a passar a mensagem inteira de uma vez, desde que a ordem e os bytes sejam os mesmos.

Comparar com segurança

import hmac

def assinatura_valida(esperada: str, recebida: str) -> bool:
    return hmac.compare_digest(esperada, recebida)

Prefira sempre compare_digest a == em assinaturas, tokens e MACs. As duas strings precisam ter o mesmo tipo (str com str, ou bytes com bytes) e, em geral, o mesmo comprimento lógico; se os formatos divergirem (hex vs Base64), normalize antes de comparar.

Assinar e verificar um webhook do zero

O padrão mais comum em APIs:

  1. o provedor calcula HMAC-SHA256(chave, corpo_bruto);
  2. envia o digest em um header (X-Signature, X-Hub-Signature-256, etc.);
  3. o seu backend lê o corpo bruto da requisição, recalcula e compara.
import hashlib
import hmac
from typing import Final

ALGORITMO: Final = hashlib.sha256


def assinar(corpo: bytes, chave: bytes) -> str:
    return hmac.new(chave, corpo, ALGORITMO).hexdigest()


def verificar(corpo: bytes, chave: bytes, assinatura_recebida: str) -> bool:
    # Aceita prefixos comuns como "sha256="
    recebida = assinatura_recebida.removeprefix("sha256=").strip()
    esperada = assinar(corpo, chave)
    return hmac.compare_digest(esperada, recebida)

Exemplo de uso em um handler (FastAPI / Starlette leem o body bruto com await request.body()):

from fastapi import FastAPI, Header, HTTPException, Request
import os

app = FastAPI()
CHAVE = os.environ["WEBHOOK_SECRET"].encode("utf-8")


@app.post("/webhooks/pagamentos")
async def webhooks_pagamentos(
    request: Request,
    x_signature: str = Header(alias="X-Signature"),
):
    corpo = await request.body()
    if not verificar(corpo, CHAVE, x_signature):
        raise HTTPException(status_code=401, detail="assinatura inválida")

    # Só depois de validar: parse do JSON
    evento = await request.json()
    return {"ok": True, "pedido": evento.get("pedido")}

Pontos que quebram integração na prática:

  • parsear JSON antes de assinarjson.dumps reordena chaves, muda espaços e quebra o digest;
  • decodificar como texto e reencodar com outro encoding;
  • ler request.json() e tentar reassinar o dict — use sempre os bytes originais do socket;
  • comparar hex com Base64 sem converter.

Hex ou Base64: siga o contrato do provedor

Alguns provedores enviam hex; outros, Base64; outros ainda prefixam o algoritmo.

import base64
import hashlib
import hmac

chave = b"segredo"
corpo = b'{"ok":true}'

bruto = hmac.new(chave, corpo, hashlib.sha256).digest()
em_hex = bruto.hex()
em_b64 = base64.b64encode(bruto).decode("ascii")

print(em_hex)
print(em_b64)

Antes de codificar a verificação, leia a documentação do emissor:

  • GitHub: header X-Hub-Signature-256: sha256=<hex>;
  • muitos PSPs brasileiros: hex ou Base64 do HMAC-SHA256/SHA512 do body;
  • alguns CRMs: HMAC sobre timestamp + "." + body para evitar replay.

Se o provedor assina timestamp + "." + body, o seu mensagem precisa ser exatamente essa concatenação — não só o JSON.

def assinar_com_timestamp(chave: bytes, timestamp: str, corpo: bytes) -> str:
    mensagem = timestamp.encode("utf-8") + b"." + corpo
    return hmac.new(chave, mensagem, hashlib.sha256).hexdigest()

Proteção contra replay com timestamp

HMAC prova autenticidade do corpo, mas não impede reenvio da mesma requisição válida minutos depois. Combine com janela de tempo:

import time


def webhook_fresco(timestamp_header: str, tolerancia_segundos: int = 300) -> bool:
    try:
        ts = int(timestamp_header)
    except (TypeError, ValueError):
        return False
    agora = int(time.time())
    return abs(agora - ts) <= tolerancia_segundos

Fluxo recomendado:

  1. validar formato do timestamp;
  2. rejeitar se estiver fora da janela (ex.: 5 minutos);
  3. só então validar o HMAC da mensagem canônica;
  4. opcionalmente gravar o event_id processado para idempotência.

Isso importa em cobranças, conciliação e qualquer efeito colateral financeiro — temas frequentes em automações Python no Brasil. Para conciliação e dinheiro com precisão decimal, veja também decimal e o guia de conciliação financeira.

Chaves secretas: geração, armazenamento e rotação

Gere com secrets, não com uuid “bonito”

import secrets

# 32 bytes → 64 chars hex; adequado para HMAC-SHA256
chave_hex = secrets.token_hex(32)
chave_bytes = bytes.fromhex(chave_hex)

Evite senhas curtas, nomes de empresa, CNPJ ou slugs de projeto como chave de webhook. Trate a chave como credencial de produção: variável de ambiente, secret manager ou cofre — nunca commit no Git. O padrão com .env e dotenv serve para desenvolvimento local; em produção, prefira o segredo injetado pelo orquestrador.

Rotação sem downtime

Muitos times mantêm duas chaves durante a troca:

def verificar_com_rotacao(
    corpo: bytes,
    assinatura: str,
    chave_atual: bytes,
    chave_anterior: bytes | None = None,
) -> bool:
    if verificar(corpo, chave_atual, assinatura):
        return True
    if chave_anterior is not None and verificar(corpo, chave_anterior, assinatura):
        return True
    return False

Publique a chave nova no provedor, aceite as duas por um período, depois remova a antiga.

Algoritmos: SHA-256, SHA-512 e o que evitar

AlgoritmoUso recomendado
HMAC-SHA256padrão seguro e amplamente suportado
HMAC-SHA512quando o contrato exige ou há alinhamento com políticas internas
HMAC-SHA1legado; só se o provedor antigo não oferecer alternativa
HMAC-MD5não use em segurança
import hashlib
import hmac

hmac.new(b"k", b"m", hashlib.sha256)
hmac.new(b"k", b"m", hashlib.sha512)

Para senhas de usuário, não use HMAC sozinho como “hash de senha”. Prefira Argon2id, scrypt ou bcrypt. hashlib.pbkdf2_hmac existe na stdlib, mas bibliotecas modernas de senha são preferíveis em apps novos — o post de hashlib detalha essa separação.

Erros comuns (e como detectar rápido)

1. Assinar o dict Python em vez dos bytes do HTTP

# Errado: ordem de chaves e espaços diferem do body original
import json, hashlib, hmac
hmac.new(chave, json.dumps(payload).encode(), hashlib.sha256)

Correto: assinar await request.body() / request.get_data() / o buffer bruto do framework.

2. Comparar com ==

# Evite
if esperada == recebida:
    ...

Use hmac.compare_digest(esperada, recebida).

3. Misturar str e bytes

# TypeError ou resultado errado conforme a versão/caminho
hmac.new("chave", "mensagem", hashlib.sha256)  # em 3.x a chave/msg devem ser bytes-like

Normalize:

def as_bytes(valor: str | bytes) -> bytes:
    return valor if isinstance(valor, bytes) else valor.encode("utf-8")

4. Logar a chave ou a assinatura completa em texto claro

Registre apenas se a verificação passou/falhou, o event_id e um prefixo curto do digest se precisar depurar. Assinaturas e chaves em log viram incidente.

5. Esquecer o prefixo sha256=

Vários provedores enviam sha256=abc123.... Faça removeprefix (Python 3.9+) ou split("=", 1) antes de comparar.

6. Achar que HMAC substitui HTTPS

HMAC sem TLS ainda expõe o corpo e pode vazar metadados. Em produção, HTTPS + HMAC é o combo mínimo para webhooks públicos.

Testes unitários com pytest

Teste o contrato, não a rede:

import hashlib
import hmac

from meuapp.webhooks import assinar, verificar

def test_assinatura_estavel():
    chave = b"teste"
    corpo = b'{"a":1}'
    sig = assinar(corpo, chave)
    assert verificar(corpo, chave, sig)
    assert verificar(corpo, chave, f"sha256={sig}")


def test_corpo_adulterado_falha():
    chave = b"teste"
    corpo = b'{"a":1}'
    sig = assinar(corpo, chave)
    assert not verificar(b'{"a":2}', chave, sig)


def test_compare_digest_tipos_iguais():
    a = hmac.new(b"k", b"m", hashlib.sha256).hexdigest()
    b = a
    assert hmac.compare_digest(a, b)

Para testes de handler HTTP, injete a chave por fixture e envie o header calculado com a mesma função de produção — evita duplicar a lógica “de teste” com uma implementação divergente.

Quando HMAC não é a ferramenta certa

  • Segredo por usuário final no browser: não coloque a chave HMAC no frontend; qualquer visitante leria o segredo.
  • Prova pública verificável por terceiros: use assinatura assimétrica (par de chaves), não HMAC simétrico.
  • Criptografar campo sensível no banco: use envelope encryption / libs de crypto; HMAC só autentica.
  • API key simples de identificação: uma API key opaca com secrets pode bastar se não houver corpo a assinar — mas para webhooks com efeito colateral, assine o body.

Mini checklist de produção

  1. Chave longa gerada com secrets, fora do repositório.
  2. Algoritmo SHA-256 ou superior, alinhado ao provedor.
  3. Assinatura sobre bytes brutos (+ timestamp se o contrato exigir).
  4. Verificação com hmac.compare_digest.
  5. Janela de tempo anti-replay + idempotência por event_id.
  6. HTTPS obrigatório no endpoint.
  7. Rotação de chave com período de sobreposição.
  8. Logs sem vazar segredo nem digest completo.
  9. Testes cobrindo corpo válido, adulterado e assinatura truncada.
  10. Documentação interna do header e do encoding (hex/Base64).

Relacionados no Python Brasil

Conclusão

hmac é pequeno, está na stdlib e resolve um problema caro: confiar em eventos externos sem abrir a porta para forjadores. O fluxo correto é simples de lembrar — chave em bytes, corpo bruto, SHA-256, compare_digest, timestamp e HTTPS — e difícil de improvisar no calor de um bug de produção.

Se o seu time integra PSP, ERP, CRM ou filas internas em Python, um helper de assinar/verificar com testes e rotação de chave elimina uma classe inteira de incidentes. Combine com hashlib para fingerprints e com secrets para gerar o material da chave. E quando a automação mexer em valores financeiros, feche o ciclo com boas práticas de decimal e conciliação.

Quer seguir no cluster de stdlib segura? Leia em seguida o guia de tempfile para artefatos temporários e o de shlex se a automação chama processos externos.

E

Equipe Python Brasil

Contribuidor do Python Brasil — Aprenda Python em Português