Skip to content
Prateeks16Public

About

Zero-dependency Go gateway that verifies inbound webhook signatures (Stripe, GitHub, Shopify) at the edge and forwards only authenticated traffic.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

24 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

HookGuard

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.

Why

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

How it works

  1. A provider POSTs a signed webhook to /hook/<provider>.
  2. 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).
  3. 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 returns 401 and forwards nothing.
  4. 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).

Quick start (Docker)

cp .env.example .env        # fill in real secrets
docker compose up --build

Or 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 -d

Only 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.

Run locally (no Docker)

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.)

Configuration

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"
}

Tests

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 module

CI 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

Supported providers

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

Web dashboard (optional)

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.)

Documentation

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.

License

MIT — see LICENSE.

About

Zero-dependency Go gateway that verifies inbound webhook signatures (Stripe, GitHub, Shopify) at the edge and forwards only authenticated traffic.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages