DEV Community

Ayi NEDJIMI
Ayi NEDJIMI

Posted on

Implementing Webhook Signature Verification (GitHub, Stripe, Slack)

When a third-party service fires a webhook at your endpoint, nothing in HTTP prevents someone else from sending a forged request to that same URL. Without signature verification, any attacker who discovers your endpoint can trigger deployments, replay payment events, or inject bot commands. GitHub, Stripe, and Slack all sign their webhook payloads with HMAC-SHA256 — but each platform has its own header format, timestamp handling, and edge cases. Getting the implementation wrong either breaks the integration silently or leaves a security gap that's easy to miss in code review.

The Core Mechanism: HMAC-SHA256 and Constant-Time Comparison

All three platforms follow the same underlying model: the sender signs the raw request body with a shared secret using HMAC-SHA256, places the hex digest in a request header, and your server recomputes the expected signature to compare.

The one rule that applies everywhere: use constant-time comparison. A regular == check is vulnerable to timing attacks — an attacker can infer the correct signature one byte at a time by measuring response latency. Python's hmac.compare_digest handles this correctly:

import hmac
import hashlib

def verify_signature(secret: bytes, body: bytes, received_sig: str, expected_prefix: str = "") -> bool:
    mac = hmac.new(secret, body, hashlib.sha256)
    expected = expected_prefix + mac.hexdigest()
    return hmac.compare_digest(expected, received_sig)
Enter fullscreen mode Exit fullscreen mode

This helper will be the foundation for all three integrations below.

GitHub Webhook Verification

GitHub puts the signature in X-Hub-Signature-256, prefixed with sha256=. The signature covers the raw request body exactly as received.

from fastapi import FastAPI, Request, HTTPException
import hmac, hashlib

app = FastAPI()
GITHUB_SECRET = b"your_webhook_secret_here"

@app.post("/webhooks/github")
async def github_webhook(request: Request):
    body = await request.body()

    sig_header = request.headers.get("X-Hub-Signature-256", "")
    if not sig_header.startswith("sha256="):
        raise HTTPException(status_code=400, detail="Missing X-Hub-Signature-256")

    expected = "sha256=" + hmac.new(GITHUB_SECRET, body, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(sig_header, expected):
        raise HTTPException(status_code=401, detail="Signature mismatch")

    event_type = request.headers.get("X-GitHub-Event", "unknown")
    payload = await request.json()
    # handle payload...
    return {"event": event_type, "status": "accepted"}
Enter fullscreen mode Exit fullscreen mode

The single most common failure here: reading request.json() before reading request.body(). Once FastAPI consumes the body stream to parse JSON, calling .body() afterward returns an empty bytes object. Always read raw bytes first.

GitHub does not embed a timestamp in its signature, which means there is no built-in replay protection. For high-impact events — merges to main, deployment triggers — you should add your own: store a hash of each processed payload in Redis or a database with a short TTL (five minutes is standard) and reject duplicates.

Stripe Webhook Verification

Stripe's Stripe-Signature header includes both a timestamp (t=) and one or more signatures (v1=). The signature covers the string {timestamp}.{raw_body}. Stripe enforces replay protection by design: you are expected to reject requests where the timestamp is more than 300 seconds old.

import time
import hmac, hashlib
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
STRIPE_SECRET = b"whsec_your_stripe_endpoint_secret"
TOLERANCE_SECONDS = 300

@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
    body = await request.body()
    sig_header = request.headers.get("Stripe-Signature", "")

    # Parse "t=...,v1=...,v1=..." format
    parts = {}
    for item in sig_header.split(","):
        k, _, v = item.partition("=")
        parts.setdefault(k.strip(), []).append(v.strip())

    timestamp = parts.get("t", [None])[0]
    signatures = parts.get("v1", [])

    if not timestamp or not signatures:
        raise HTTPException(status_code=400, detail="Malformed Stripe-Signature header")

    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        raise HTTPException(status_code=400, detail="Webhook timestamp too old")

    signed_payload = f"{timestamp}.".encode() + body
    expected = hmac.new(STRIPE_SECRET, signed_payload, hashlib.sha256).hexdigest()

    if not any(hmac.compare_digest(s, expected) for s in signatures):
        raise HTTPException(status_code=401, detail="Invalid Stripe signature")

    return {"status": "ok"}
Enter fullscreen mode Exit fullscreen mode

Note the loop over all v1= entries: during a webhook secret rotation, Stripe sends both the old and new signatures in the same header. Your endpoint needs to accept either, otherwise you'll drop events during the rotation window. This is a detail that bites teams the first time they rotate a secret in production.

Slack Webhook Verification

Slack uses X-Slack-Signature (with a v0= prefix) and X-Slack-Request-Timestamp as separate headers. The signed payload format is v0:{timestamp}:{raw_body} — note the colon separators, not a period like Stripe.

import time
import hmac, hashlib
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
SLACK_SIGNING_SECRET = b"your_slack_app_signing_secret"
TOLERANCE_SECONDS = 300

@app.post("/webhooks/slack")
async def slack_webhook(request: Request):
    body = await request.body()

    timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
    slack_sig = request.headers.get("X-Slack-Signature", "")

    if not timestamp or not slack_sig:
        raise HTTPException(status_code=400, detail="Missing Slack signature headers")

    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        raise HTTPException(status_code=400, detail="Request too old")

    basestring = f"v0:{timestamp}:".encode() + body
    expected = "v0=" + hmac.new(SLACK_SIGNING_SECRET, basestring, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(slack_sig, expected):
        raise HTTPException(status_code=401, detail="Invalid Slack signature")

    return {"challenge": (await request.json()).get("challenge")}
Enter fullscreen mode Exit fullscreen mode

The last line handles Slack's URL verification challenge — when you first configure the endpoint in Slack's app settings, it sends a POST with a challenge field that you must echo back. This only runs after the signature check passes.

Common Pitfalls to Avoid

Raw bytes, always. Web frameworks that automatically parse JSON or form data will give you a deserialized object. Re-serializing it for signing produces different bytes due to whitespace and key ordering. Read raw bytes before any parsing.

HTTPS is non-negotiable. Signature verification is meaningless over plain HTTP — a man-in-the-middle can replace both the body and the signature simultaneously. Enforce HTTPS at the load balancer or reverse proxy level and return a hard error on plain HTTP requests.

Do not log secrets in startup output. Webhook secrets are credentials. Storing them in environment variables is fine; printing WEBHOOK_SECRET=... at boot is not.

Test rejection, not just acceptance. CI pipelines commonly test the happy path — a valid signature succeeds. Add a negative test that sends a tampered body and confirms your endpoint returns 401. If that test doesn't exist, you won't notice when a future refactor breaks the verification path.

For a structured list of webhook and API security controls worth implementing, our security hardening checklists cover this and related patterns in both PDF and Excel formats.

The Takeaway

GitHub, Stripe, and Slack all use HMAC-SHA256, but the differences in header names, payload format, and timestamp handling are enough to cause silent verification failures if you copy-paste between integrations. The implementation checklist is short:

  • Read raw bytes before any deserialization
  • Use hmac.compare_digest — not ==
  • Enforce a timestamp tolerance window (add one yourself if the platform doesn't require it)
  • Handle multi-signature headers during secret rotation (Stripe)
  • Write a test that sends an invalid signature and confirms rejection

The patterns above work without any third-party webhook library. The dependencies are hmac and hashlib, both in the Python standard library. Less is more when it comes to security-critical code paths.


I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.

Top comments (0)