Verifying webhook signatures Jump to heading

A webhook receiver is a URL on the internet that performs actions when it receives a request: deploy a branch, label a pull request, trigger a release. If it trusts any request that arrives, anyone who learns the URL β€” from a log, a screenshot, a misconfigured proxy β€” can trigger those actions with a payload of their choosing. Forges solve this by signing each delivery with a secret shared between the forge and the receiver. The receiver recomputes the signature over the exact bytes it received and rejects anything that does not match. Getting this right has a few sharp edges: the signature covers the raw body, not parsed JSON; comparison must be constant-time; and a valid delivery can be replayed unless you check delivery IDs. This page builds a correct verifier, within forge API and webhook automation.

When to use this approach Jump to heading

  • You run any service that receives webhooks from a forge.
  • An existing receiver checks nothing, or checks a token in the URL.
  • You are building a bot, as in building a slash-command bot for pull requests, that runs as a service rather than as a workflow.
  • You need to rotate a webhook secret that may have leaked.

Step 1 β€” Configure a strong secret on the forge Jump to heading

Generate a long random secret and set it on the webhook. Store it in your receiver’s secret manager, never in source code.

secret=$(openssl rand -hex 32)
gh api -X POST "repos/$OWNER/$REPO/hooks" \
  -f name=web -F active=true -f 'events[]=pull_request' -f 'events[]=push' \
  -f "config[url]=https://hooks.example.com/forge" \
  -f "config[content_type]=json" -f "config[secret]=$secret"

Different forges carry the signature in different headers and formats.

How forges authenticate webhook deliveriesGitHub and Gitea send an HMAC-SHA256 of the raw body in a header, prefixed on GitHub with sha256=. GitLab sends the configured secret token itself in a header, which must be compared but proves less because it is not bound to the body. Each also sends a unique delivery identifier.HeaderMethodGitHubX-Hub-Signature-256HMAC-SHA256, sha256=…Gitea / ForgejoX-Gitea-SignatureHMAC-SHA256, hexGitLabX-Gitlab-Tokenshared token, comparedelivery IDX-GitHub-Delivery / X-Gitlab-Event-UUIDreplay checka body-bound HMAC proves both origin and integrity; a static token proves only origin

Step 2 β€” Compute the HMAC over the raw body Jump to heading

The signature covers the exact bytes the forge sent. Parsing the JSON and re-serialising it changes whitespace and key order and breaks verification. Read the raw body first, verify, then parse.

# receiver.py β€” minimal verified webhook handler (standard library only)
import hmac, hashlib, json, os
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ["WEBHOOK_SECRET"].encode()

def valid_signature(body: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")

def handle(event, payload):
    ...   # enqueue work here; keep the request fast

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))   # raw bytes
        if not valid_signature(body, self.headers.get("X-Hub-Signature-256")):
            self.send_response(401); self.end_headers(); return
        event = self.headers.get("X-GitHub-Event")
        payload = json.loads(body)                                            # only now parse
        self.send_response(204); self.end_headers()
        handle(event, payload)

HTTPServer(("", 8080), Handler).serve_forever()

hmac.compare_digest compares in constant time. A plain == returns as soon as a character differs, which leaks, through timing, how much of a forged signature was right.

Verifying a delivery before actingThe receiver reads the raw request body, computes HMAC-SHA256 with the shared secret, compares it with the signature header in constant time, rejects mismatches with 401, checks the delivery ID against recently seen ones, and only then parses the JSON and handles the event.Raw bodybytes, unparsedHMAC-SHA256shared secretCompareconstant timeDelivery IDseen before?Parse + handleonly if validparse after verifying β€” a framework that parses first may have changed the bytes
# Verification: a request with a wrong signature is rejected
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://hooks.example.com/forge \
  -H 'X-Hub-Signature-256: sha256=deadbeef' -H 'X-GitHub-Event: ping' -d '{}'     # 401

Step 3 β€” Reject replays Jump to heading

An attacker who captures one valid delivery β€” from a proxy log, say β€” can resend it, and its signature will still verify. Each delivery carries a unique ID; remember recent IDs and refuse duplicates.

import time
SEEN = {}            # delivery_id -> timestamp (use Redis or a database in production)
def fresh(delivery_id: str, window: int = 3600) -> bool:
    now = time.time()
    for k, t in list(SEEN.items()):
        if now - t > window: del SEEN[k]
    if not delivery_id or delivery_id in SEEN:
        return False
    SEEN[delivery_id] = now
    return True

Forges redeliver failed deliveries with the same ID, so a duplicate can also be a legitimate retry. Treat duplicates as β€œalready processed” β€” respond with success and do nothing β€” rather than as an error.

Step 4 β€” Handle GitLab’s token header Jump to heading

GitLab sends the configured secret token in X-Gitlab-Token rather than a body signature. Compare it in constant time, and remember it proves only that the sender knows the token; protect it accordingly and use HTTPS.

def valid_gitlab(header: str) -> bool:
    return hmac.compare_digest(os.environ["GITLAB_WEBHOOK_TOKEN"], header or "")

Newer GitLab versions can also send signing tokens; when available, prefer body-bound verification as for the other forges.

Step 5 β€” Rotate secrets without dropping deliveries Jump to heading

When a secret may have leaked, rotate it. To avoid rejecting deliveries during the switch, let the receiver accept either the old or the new secret for a short window, then update the forge, then remove the old secret.

SECRETS = [s.encode() for s in os.environ["WEBHOOK_SECRETS"].split(",")]   # "new,old" during rotation
def valid_any(body, header):
    return any(hmac.compare_digest("sha256=" + hmac.new(s, body, hashlib.sha256).hexdigest(), header or "")
               for s in SECRETS)
Rotating a webhook secret with no failed deliveriesThe receiver is deployed accepting both the old and new secrets. The forge webhook is updated to sign with the new secret. Once deliveries verify with the new secret, the old one is removed from the receiver, closing the window.Deploy receiveraccepts old + newTUpdate forgesigns with newT+5 minConfirmnew verifiesT+10 minRemove oldnew onlyT+1 hthe order matters: receiver first, forge second

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Is restricting by source IP enough? Jump to heading

Forges publish the IP ranges they send from, and allow-listing them adds defence in depth. It is not a substitute for signatures: ranges change, and other tenants of the same platform may share them.

My framework parses JSON automatically. How do I get the raw body? Jump to heading

Most frameworks provide a raw-body option or middleware hook. Use it on the webhook route only; verification must see the exact bytes the forge signed.

Should the receiver do the work synchronously? Jump to heading

Respond quickly β€” forges time out after a few seconds and retry β€” and do the work in a background job. Acknowledge with 2xx after verification and enqueueing, as with post-receive hooks for notifications and deploys.