HMAC for Beginners: Verifying Webhooks and API Requests
A few months back, I was debugging a payment integration late at night. Stripe was firing webhooks to my endpoint, my server was receiving them — but something felt off. The orders were updating even when I manually sent fake POST requests from curl. That's when it clicked: I wasn't actually verifying where the request came from. I was just trusting anyone who knocked on the door.
That was my introduction to HMAC. And if you've ever wired up a webhook endpoint and skipped the signature check because "I'll add security later," this one's for you.
What HMAC Actually Does
HMAC stands for Hash-based Message Authentication Code. The name is dense, but the idea is surprisingly elegant.
You already know what a hash function does — it takes any input and produces a fixed-length fingerprint. SHA-256 is a common one. You can throw a thousand-word essay into SHA-256 and get back a 64-character hex string. Change one letter of the essay and the hash looks completely different.
A regular hash proves data integrity: if the hash matches, the content hasn't been tampered with. But it doesn't prove origin. Anyone can compute SHA256("your data"). There's nothing secret about it.
HMAC solves this by mixing in a shared secret key:
HMAC(key, message) → authentication tag
Only someone who knows the key can produce the correct tag for a given message. When Stripe sends you a webhook, they compute HMAC(your_webhook_secret, payload) and include the result in a header. Your server does the same computation independently. If the tags match, you know two things: the payload wasn't modified in transit, and only someone who knows your secret (i.e., Stripe) could have generated it.
This is the key insight that took me a while to internalize: HMAC doesn't encrypt anything. The payload is still readable. It's a signature, not a lock. The goal is authentication and integrity, not confidentiality.
The Stripe Webhook Case Study
Let's walk through a real implementation. Stripe signs every webhook with your endpoint's secret (starts with whsec_). Their signature arrives in the Stripe-Signature header, which looks roughly like this:
t=1719182400,v1=3b2f8c9a1d4e6f0b2a3c...
The t value is a Unix timestamp. The v1 value is the HMAC-SHA256 tag. To verify it yourself, you recreate the signed payload string Stripe uses:
signed_payload = timestamp + "." + raw_request_body
Then you compute HMAC-SHA256(webhook_secret, signed_payload) and compare it to the v1 value from the header.
Here's what that looks like in Python:
import hmac
import hashlib
def verify_stripe_webhook(payload_body: bytes, sig_header: str, secret: str) -> bool:
parts = dict(item.split("=", 1) for item in sig_header.split(","))
timestamp = parts.get("t")
received_sig = parts.get("v1")
signed_payload = f"{timestamp}.{payload_body.decode('utf-8')}"
expected_sig = hmac.new(
secret.encode("utf-8"),
signed_payload.encode("utf-8"),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected_sig, received_sig)
Notice that last line uses hmac.compare_digest instead of ==. This is intentional. Regular string comparison can leak timing information — an attacker who can send millions of requests might detect that a signature starting with abc takes microseconds longer to reject than one starting with xyz. compare_digest runs in constant time regardless of where the strings differ. It's a subtle but important detail.
Also notice the timestamp. Stripe includes it so you can reject old webhooks. If the timestamp is more than five minutes in the past, you should reject the request even if the signature is valid. This prevents replay attacks — someone capturing a legitimate webhook and re-sending it later.
GitHub Webhooks: Same Idea, Slightly Different Shape
GitHub uses the same underlying mechanism but different packaging. When you configure a webhook in a GitHub repository, you set a secret. GitHub computes HMAC-SHA256(secret, payload) and sends the result in the X-Hub-Signature-256 header, prefixed with sha256=:
X-Hub-Signature-256: sha256=d4c3b2a1...
Verification in Node.js:
const crypto = require('crypto');
function verifyGitHubWebhook(payload, signature, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
Same pattern: compute HMAC on the raw bytes, compare with constant-time function. The raw bytes part matters — if you parse the JSON first and re-serialize it, field ordering might change and the hash won't match. Always verify against the original request body before any parsing.
Why Not Just Use a Secret Token in a Header?
A fair question. Some APIs do this — they just put your API key directly in an Authorization header. What's wrong with that?
The problem is that the secret itself travels over the wire. Even over HTTPS, if there's a misconfigured proxy, a logging system that captures headers, or a server-side bug that logs requests — your secret is exposed. Once exposed, an attacker can forge any request they want indefinitely.
With HMAC, the secret never travels. What travels is a derivative of the secret applied to a specific message. Even if an attacker captures a hundred legitimate HMAC tags, they can't reverse-engineer the key from them (that would require breaking SHA-256, which isn't happening). And each tag only works for its specific payload — it's not reusable for different content.
There's a deeper point here about what HMAC actually proves. It demonstrates shared knowledge. If I send you a message with a valid HMAC tag, I'm essentially proving: "I know the secret we both agreed on, and I'm applying it specifically to this message right now." It's a handshake that requires no back-and-forth, no certificate infrastructure, no third-party authority.
The Base64 and Encoding Layer
When you look at real HMAC output in the wild, you'll often see it encoded as either hex or base64. The raw HMAC output is bytes — not printable characters. To put it in a header or JSON field, you need to encode it.
Hex encoding is straightforward: each byte becomes two hex characters. A 32-byte SHA-256 HMAC becomes a 64-character hex string. This is what Stripe and GitHub use.
Base64 is more compact — 32 bytes becomes 44 characters. You'll see it in JWT signatures (HS256 is HMAC-SHA256 with base64url encoding) and some API authentication schemes. The algorithm is the same; only the output representation differs.
If you're ever using an online HMAC calculator to debug a webhook, make sure you match the encoding. An HMAC computed correctly but displayed in base64 when you're expecting hex will look completely wrong, and you'll spend an hour convinced your code is broken when the tool is just using different output formatting.
Building Your Own HMAC-Protected Endpoint
Let's say you're building a service that sends webhooks to your customers. You want to sign your outgoing payloads the same way Stripe does. The workflow:
- Generate a unique secret per customer (use a cryptographically secure random generator —
secrets.token_hex(32)in Python,crypto.randomBytes(32)in Node). - Store that secret server-side. Give it to the customer once, securely.
- When sending a webhook, compute
HMAC-SHA256(secret, payload)and include the hex digest in a header likeX-Signature: sha256=.... - Document exactly what you're signing — raw body bytes, specific encoding, timestamp format — so customers can implement verification without guessing.
The documentation step is genuinely important. Stripe's HMAC implementation is solid, but their docs explaining exactly how the signed string is constructed saved me hours. When you're on the receiving end trying to verify and it's not matching, the most common culprit is a mismatch in what exactly was hashed — did the timestamp go in? What encoding was the body in? Was there a trailing newline?
A Quick Note on Algorithm Choice
HMAC-SHA256 is the current standard. HMAC-MD5 and HMAC-SHA1 still exist in older systems — they're not broken in the HMAC context the way bare MD5/SHA1 are for collision resistance, but SHA256 is preferred and expected. If you're starting fresh, always use SHA256 or SHA512. The performance difference is negligible on modern hardware.
What I Do Differently Now
After that late-night incident, I built a small middleware layer for all my webhook endpoints. Before any handler runs, it checks the signature and the timestamp. If either fails, it returns 400 immediately and logs the attempt. No business logic runs. No database queries. The expensive work only happens after the request proves it's legitimate.
It's maybe thirty lines of code. And it means I can sleep soundly knowing that someone blindly hitting my /webhooks/payment endpoint with crafted payloads isn't going to create phantom orders in my system.
HMAC is one of those concepts that feels abstract until you see what happens without it. Then it becomes the first thing you add to every endpoint that accepts data from the outside world.