Files
webhook-server/docs/recipes/github-style-hmac.md
T
justin 85a700841b
CI / build (pull_request) Has been cancelled
Sync from GitHub main: v0.1.1, v0.1.2, wiki sync
Squashes the work that landed on GitHub via PRs #2 (v0.1.1), #3
(v0.1.2), and #4 (wiki sync) into a single commit on Gitea so both
remotes converge. Content is identical to github/main; commit history
is split for branching reasons (Gitea was merged via PR #1 long ago,
GitHub used squash merges from then on, so the SHAs diverged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:13:31 -04:00

123 lines
4.8 KiB
Markdown

# Recipe: GitHub-style HMAC-signed webhook
GitHub, Stripe, Slack, Shopify, and most SaaS providers sign their outbound webhooks with HMAC. The receiver computes the same HMAC over the request body using a shared secret and rejects the request if the signatures don't match. Webhook Server has this built in — you just point a real GitHub webhook at your endpoint.
## What we're building
A webhook URL that GitHub calls on every push to a repo. The server runs a PowerShell script that pulls the latest commit and triggers a deployment. Authentication is HMAC-SHA256 over the request body, using the secret you configured in GitHub's webhook settings.
## On the GitHub side
In your repo: **Settings → Webhooks → Add webhook**.
| Field | Value |
|---|---|
| Payload URL | `https://hooks.contoso.com/hook/gh-deploy` (yes, HTTPS — GitHub enforces it for public hosts) |
| Content type | `application/json` |
| Secret | Generate a long random string. Copy it for the next step. |
| SSL verification | Enable |
| Events | Just `push` |
Save. GitHub immediately delivers a `ping` event for testing. You'll see it in **Recent Deliveries** with whatever response code your server returns.
## The PowerShell deployment script
`C:\Scripts\gh-deploy.ps1`:
```powershell
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$payload = $input | ConvertFrom-Json
# Verify the event type via the X-GitHub-Event header passed as an env var
$event = $env:WEBHOOK_HEADER_X_GITHUB_EVENT
if ($event -eq 'ping') {
"got ping from $($payload.repository.full_name)"
return
}
if ($event -ne 'push') {
Write-Error "ignoring $event event"
}
$repo = $payload.repository.full_name
$branch = $payload.ref -replace '^refs/heads/', ''
$sha = $payload.after
if ($branch -ne 'main') {
"ignoring push to $branch"
return
}
$repoDir = "C:\Deploys\$($payload.repository.name)"
if (-not (Test-Path $repoDir)) {
git clone "https://github.com/$repo.git" $repoDir
}
Push-Location $repoDir
try {
git fetch --all
git reset --hard $sha
# ...your build/deploy steps here...
& npm ci
& npm run build
Restart-Service MyAppService
}
finally {
Pop-Location
}
"deployed $repo @ $sha"
```
## Configure the endpoint
**File → New endpoint**:
| Section | Setting | Value |
|---|---|---|
| Identity | Slug | `gh-deploy` |
| Auth | Mode | **HMAC** |
| Auth | HMAC secret | paste the GitHub-side secret |
| Auth | HMAC header | `X-Hub-Signature-256` *(GitHub's default)* |
| Allowed clients | | `140.82.112.0/20`, `192.30.252.0/22` *(GitHub's webhook IP ranges; check [docs.github.com](https://api.github.com/meta) for the live list)* |
| Executor | Type | **Windows PowerShell** |
| Executor | Script path | `C:\Scripts\gh-deploy.ps1` |
| Data passing | JSON body to stdin | ✓ |
| Data passing | Headers/query as env vars | ✓ *(needed so `WEBHOOK_HEADER_X_GITHUB_EVENT` is set)* |
| Run as | Identity | **Service** (default) — assumes the deployment is local |
| Response | Mode | **Async** *(GitHub times out fast; don't make it wait for the build)* |
| Response | Timeout (sec) | `600` |
Save.
## What HMAC does for you here
GitHub computes `sha256(body, secret)` and sends it as `sha256=<hex>` in `X-Hub-Signature-256`. Webhook Server computes the same hash, verifies in fixed time, and rejects (401) on mismatch.
This means:
- A request with a tampered body fails the check
- A captured request can be **replayed verbatim** (the signature is valid for that body) — if that matters, GitHub also includes a `X-GitHub-Delivery` ID and timestamp you can deduplicate against
- The secret never travels over the network — only the digest does, so HTTPS is for confidentiality of the body, not the secret
## Adapting for Stripe, Slack, etc.
Same pattern, different headers and signing details. The four HMAC fields in the editor cover all common variants:
| Provider | Header | Prefix | Encoding | Algorithm |
|---|---|---|---|---|
| GitHub | `X-Hub-Signature-256` | `sha256=` | hex | SHA-256 |
| Stripe | `Stripe-Signature` | (none — but Stripe's format is multipart, see below) | hex | SHA-256 |
| Slack | `X-Slack-Signature` | `v0=` | hex | SHA-256 |
| Generic / custom | configurable | configurable | configurable | SHA-1 / SHA-256 / SHA-512 |
**Stripe** is special: their `Stripe-Signature` header has the format `t=<timestamp>,v1=<sig>,v0=<sig>`, where `v1` is HMAC-SHA256 of `<timestamp>.<body>`. Webhook Server's straight HMAC check doesn't match Stripe's signed-with-timestamp scheme. Workarounds:
- Use **Bearer auth** on Stripe webhooks instead, since you already control the secret
- Or do unauthenticated + IP allowlist + a script-side signature check using their official validation library
For everything that's "GitHub-shaped" (signed body, raw HMAC), the built-in HMAC mode is the right pick.