A self-hosted gateway that verifies inbound webhook signatures at the network edge and forwards only authenticated traffic to your application. Zero external dependencies in the shipped binary; single static binary; Stripe, GitHub, Shopify, and PayPal supported.
Every webhook provider signs its requests differently (different headers, algorithms, encodings, replay rules). Verifying them correctly is fiddly and error-prone, and a subtle mistake silently disables the security. HookGuard does the verification once, in front of your app. Your app then trusts one thing — the Gateway signature — instead of implementing N bespoke verifiers.
verifies provider signature
Stripe ─┐ attaches Gateway signature
GitHub ─┤
Shopify─┼──▶ HookGuard ───────────────────────▶ Your app
PayPal ─┘ (port 9000) internal network (verifies ONE signature)
app is NOT exposed
- A provider POSTs a signed webhook to
/hook/<provider>. - HookGuard reads the raw body (never parsing it — parsing then re-serializing would change the bytes and break the signature) and verifies the provider's signature: HMAC compare in constant time for the symmetric providers, or an RSA-SHA256 check against PayPal's published certificate for PayPal, plus a replay-window check where the provider includes a timestamp (Stripe).
- On success it re-signs the body with a single
INTERNAL_SECRET(the Gateway signature, binding the verified provider name) and forwards the unchanged bytes upstream. On failure it returns401and forwards nothing. - Your app verifies that one Gateway signature and trusts the payload.
The trust boundary has two reinforcing halves: the Gateway signature (an
attacker without INTERNAL_SECRET cannot forge a request the app will accept) and
network isolation (the app is never published; only the gateway can reach it).
cp .env.example .env # fill in real secrets
docker compose up --buildOr skip the build and pull the images CI publishes on every green push to
main (see PRODUCTION.md §5 for pinning a specific
commit):
docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -dOnly the gateway is published (:9000). The upstream has no ports: mapping — it
is unreachable from the host; only the gateway, on the shared internal network,
can talk to it.
Send a webhook (example signs a Stripe payload with openssl):
SECRET=whsec_change-me # must match STRIPE_SECRET
BODY='{"id":"evt_1","amount":4242}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')
curl -s -X POST localhost:9000/hook/stripe \
-H "Stripe-Signature: t=$TS,v1=$SIG" \
--data "$BODY"
# -> "ok" (verified, forwarded, upstream accepted)A tampered body, wrong secret, or stale timestamp returns 401 and never reaches
your app.
go build -o hookguard .
go build -o upstream ./cmd/upstream
INTERNAL_SECRET=dev ./upstream & # listens on :8080
INTERNAL_SECRET=dev STRIPE_SECRET=whsec_change-me \
GITHUB_SECRET=gh SHOPIFY_SECRET=shop ./hookguard # listens on :9000(config.json points the routes at localhost:8080 for local runs;
config.docker.json points them at the upstream service for Compose.)
config.json maps each inbound path to a provider, an upstream URL, an optional
replay window, and the name of the env var holding the secret (secrets
themselves live in the environment, never in the file):
{
"path": "/hook/stripe",
"provider": "stripe",
"upstream": "http://upstream:8080/stripe",
"replay_window": "5m",
"secret_env": "STRIPE_SECRET"
}PayPal has no shared secret — it verifies asymmetrically against PayPal's own
certificate — so its route carries webhook_id (the webhook subscription ID
from your PayPal listener, not a secret) instead of secret_env:
{
"path": "/hook/paypal",
"provider": "paypal",
"upstream": "http://upstream:8080/paypal",
"webhook_id": "WH-CHANGE-ME"
}go test ./... # unit + integration + differential harness
go test -v -run Differential . # the correctness proof (vs official libraries)
(cd web && go test ./...) # the Console is a separate moduleCI runs all of the above on every push, plus go vet, gofmt, and a build of
all three Docker images (.github/workflows/ci.yml).
The differential harness cross-checks each verifier against the provider's own
official library (stripe-go, go-github) — these are test-only
dependencies; the gateway binary imports nothing outside the standard library:
go list -deps . | grep -E 'stripe|go-github' # prints nothing| Provider | Header | Algorithm | Notes |
|---|---|---|---|
| Stripe | Stripe-Signature |
HMAC-SHA256 hex | timestamped (t=,v1=), replay window |
| GitHub | X-Hub-Signature-256 |
HMAC-SHA256 hex | sha256= prefix, raw UTF-8 |
| Shopify | X-Shopify-Hmac-SHA256 |
HMAC-SHA256 base64 | base64-encoded output |
| PayPal | paypal-transmission-sig (+-id/-time/-cert-url) |
RSA-SHA256 (asymmetric) | cert fetched from paypal-cert-url, host-pinned to *.paypal.com and chain-validated before use; configured via webhook_id, no shared secret |
docker compose up also boots a Console — a landing page, a session-
authenticated dashboard for managing routes, a Providers page carrying each
provider's setup guide and its recent traffic, and a live view of every accepted/
rejected webhook with its reason — at http://localhost:7000. It's a separate
Go module (/web) with its own two dependencies (a pure-Go SQLite driver and
Argon2id); the gateway itself stays zero-dependency regardless of whether the
Console is running. Sign-up is closed by default — see
PRODUCTION.md §8 for the
one-time bootstrap flow to create the first (admin) account. Remove the
console service from docker-compose.yml if you don't want it at all.
(A screenshot belongs here — not captured yet; this environment's browser
tooling doesn't support it. See web/DESIGN.md for the full visual spec.)
docs/BUILD-GUIDE.md — a from-scratch walkthrough of the whole project with every concept and code block explained.
PRODUCTION.md — the deployment checklist: TLS, secrets, routes, the PayPal smoke test, and the Console bootstrap.
docs/DEPLOY-FLY.md — deploying the three services to Fly.io, where the upstream gets no public IP at all and the trust boundary is enforced by the platform.
MIT — see LICENSE.