HMAC — How Webhooks Know You’re Not Lying
Any server can send a POST request to your webhook endpoint. HMAC signatures are how legitimate senders prove they wrote the payload — and how you verify it.
Every time Stripe charges a card, it fires a webhook. Every time GitHub merges a pull request, it fires a webhook. These platforms are sending POST requests to a URL you gave them — but here’s the uncomfortable truth: anyone else can send a POST request to that same URL.
So how does your server know the request actually came from Stripe and not from some attacker who guessed your endpoint URL? The answer is HMAC — and once you understand it, you’ll see why it’s the standard approach across every serious API platform.
The Problem: Anyone Can POST to Your Endpoint
Webhook endpoints are just URLs. They’re publicly reachable (they have to be, for the sender to reach them), and they accept POST requests. There’s nothing stopping a malicious actor from crafting a fake payload and sending it to your endpoint.
Imagine your webhook handler does this when it receives an event:
if event["type"] == "payment.completed":
fulfill_order(event["data"]["order_id"])
An attacker who knows your endpoint URL could send a fake payment.completed event with any order ID they like. Without verification, your server would happily fulfill orders that were never paid for.
You need a way to verify that the payload was written by someone who holds a secret you both share — without transmitting that secret in the request.
What Is HMAC?
HMAC significa Código de Autenticação de Mensagem Baseado em Hash. It’s a construction that combines a cryptographic hash function (typically SHA-256) with a secret key to produce a signature. The signature proves two things:
- Autenticidade — the message was created by someone who holds the secret key
- Integrity — the message hasn’t been modified in transit
The key property of HMAC: you cannot produce a valid signature without knowing the secret. And you cannot reverse the signature to recover the secret. It’s a one-way proof.
HMAC vs. a plain hash
A plain hash (like SHA-256) of the payload solves the integrity problem but not authenticity. An attacker who intercepts a valid payload could recalculate the hash of a modified payload. HMAC mixes your secret key into every step of the hashing process, so without the key, you can’t produce a matching signature even if you know the exact hash algorithm being used.
How Webhook HMAC Works
The flow has three steps: key exchange, signing, and verification.
Step 1: Key exchange (happens once)
When you configure a webhook with a platform like Stripe or GitHub, they generate a webhook secret and show it to you once. You store it server-side (never in client-side code or public repos). That’s it — the secret never travels over the wire again.
Step 2: The sender signs the payload
Before sending the webhook, the platform computes an HMAC signature over the raw request body using your shared secret:
signature = HMAC-SHA256(secret_key, request_body)
The signature is then attached to the request, usually in a header like X-Hub-Signature-256 (GitHub) or Stripe-Signature (Stripe). The raw payload travels in the body unchanged.
Step 3: You verify on receipt
When your server receives the webhook, you recompute the same HMAC using the raw body and your stored secret, then compare the result to the signature in the header. If they match, the payload is authentic and unmodified. If they don’t, reject it.
expected = HMAC-SHA256(your_secret, raw_body)
if not constant_time_equal(expected, header_signature):
return 401
Perceber constant-time comparison — we’ll come back to why that matters.
Exemplos do Mundo Real
Stripe
Stripe sends a Stripe-Signature header containing a timestamp and one or more signatures:
Stripe-Signature: t=1679000000,v1=abc123...,v0=oldformat...
The signature is computed over timestamp.payload (concatenated with a period). Including the timestamp lets Stripe defend against replay attacks — if an attacker captures a valid signed request and resends it later, you can reject it because the timestamp is too old.
GitHub
GitHub sends an X-Hub-Signature-256 header in the format sha256=<hex_digest>. The signature is HMAC-SHA256 of the raw body using the webhook secret you configured in your repository settings.
Shopify
Shopify uses an X-Shopify-Hmac-Sha256 header with a Base64-encoded HMAC-SHA256 signature — same concept, different encoding.
Verifying in Code
Here’s how verification looks across three common languages. The pattern is identical — only the library calls differ.
Pitão
import hmac
import hashlib
def verify_webhook(secret: str, payload: bytes, signature_header: str) -> bool:
expected = hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
received = signature_header.removeprefix("sha256=")
return hmac.compare_digest(expected, received)
Node.js
const crypto = require('crypto');
function verifyWebhook(secret, rawBody, signatureHeader) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const received = signatureHeader.replace('sha256=', '');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(received)
);
}
PHP
function verifyWebhook(string $secret, string $rawBody, string $signatureHeader): bool {
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
return hash_equals($expected, $signatureHeader);
}
Mistakes That Break Security
1. Using == instead of constant-time comparison
Standard string equality (==, ===) short-circuits as soon as it finds a mismatch. This creates a timing side channel: an attacker can measure how long your server takes to reject different signatures. Strings that share a longer prefix take slightly longer to reject. With enough requests, an attacker can use this to reconstruct a valid signature byte by byte.
Always use constant-time comparison: hmac.compare_digest() in Python, crypto.timingSafeEqual() in Node.js, hash_equals() in PHP.
2. Parsing the body before verifying
HMAC is computed over the raw bytes of the request body. If you parse the JSON first and then re-serialize it to verify, you may get a different byte sequence (different key ordering, whitespace, encoding). Always capture the raw body before your framework’s body parser touches it, then verify against that.
3. Not checking replay attacks
A valid signed request is valid forever — unless you check the timestamp. If a platform includes a timestamp in its signature scheme (Stripe does; GitHub doesn’t), reject requests where the timestamp is older than a few minutes. This prevents an attacker from capturing and replaying a legitimate request.
4. Hardcoding the secret in source code
Webhook secrets should live in environment variables or a secrets manager, never committed to version control. A leaked secret means an attacker can forge any payload forever — until you rotate it.
What HMAC Doesn’t Protect Against
HMAC proves the payload was signed by someone with the secret. It does não protect against:
- A compromised sender — if Stripe’s signing infrastructure were compromised, fake events would still have valid signatures
- Replay attacks — unless you also validate a timestamp or nonce
- Confidentiality — HMAC doesn’t encrypt anything; the payload travels in plaintext (though HTTPS handles this)
For most webhook integrations, HMAC over HTTPS covers everything you need.
Instale nossas extensões
Adicione ferramentas de IO ao seu navegador favorito para acesso instantâneo e pesquisa mais rápida
恵 O placar chegou!
Placar é uma forma divertida de acompanhar seus jogos, todos os dados são armazenados em seu navegador. Mais recursos serão lançados em breve!
Ferramentas essenciais
Ver tudo Novas chegadas
Ver tudoAtualizar: Nosso ferramenta mais recente was added on Mai 24, 2026
