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.
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.
# 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) 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.
Related Jump to heading
- Forge API & Webhook Automation β the parent topic.
- Handling API Rate Limits in Git Automation β the API calls your handler makes next.
- Responding to a Leaked Credential β when a webhook secret leaks.
- Keeping Secrets Out of CI Logs β avoiding the leak in the first place.