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.

HMAC — How Webhooks Know You’re Not Lying 1
إعلان · حذف؟

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 تعني كود تحقق الرسالة القائم على التجزئة. 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:

  • أصالة — 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

يلاحظ constant-time comparison — we’ll come back to why that matters.

أمثلة واقعية

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 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.

بايثون

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)

نود.جي اس

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 لا 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.

هل تريد حذف الإعلانات؟ تخلص من الإعلانات اليوم

تثبيت ملحقاتنا

أضف أدوات IO إلى متصفحك المفضل للوصول الفوري والبحث بشكل أسرع

أضف لـ إضافة كروم أضف لـ امتداد الحافة أضف لـ إضافة فايرفوكس أضف لـ ملحق الأوبرا

وصلت لوحة النتائج!

لوحة النتائج هي طريقة ممتعة لتتبع ألعابك، يتم تخزين جميع البيانات في متصفحك. المزيد من الميزات قريبا!

إعلان · حذف؟
إعلان · حذف؟
إعلان · حذف؟

ركن الأخبار مع أبرز التقنيات

شارك

ساعدنا على الاستمرار في تقديم أدوات مجانية قيمة

اشتري لي قهوة
إعلان · حذف؟