hookgate is a lightweight webhook gateway: a single static binary that checks each incoming webhook against its sender's signature or token and forwards only authentic requests, byte for byte, to your OpenTelemetry Collector, ClickHouse, Vector or any HTTP backend.
Configuration · Releases · Example · Security
Data pipelines are built to accept whatever arrives. A webhook endpoint in front of one is a public write path: anyone who finds the URL can insert rows. Most pipeline receivers check a static header at best, and few can verify an HMAC signature over the raw body, let alone the different scheme each sender uses.
hookgate closes that path:
- Verifies per sender. Static tokens and HMAC-SHA1/256/512 signatures in hex, base64 or base64url, with prefixes and multi-signature headers. Every check of a source must pass.
- Fails closed. A missing or too-short secret takes its own source offline (
503), never open. Unknown config keys stop startup. - Forwards exactly what it verified. The upstream receives the verified bytes and an allowlist of headers, nothing else, with retries across instances.
- Costs almost nothing. One ~16 MB distroless image, no database, no queue, Prometheus metrics built in.
flowchart LR
A[Sender A] --> G
B[Sender B] --> G
G[hookgate<br/>limits → checks → forward] -->|verified bytes| U[(Collector / ClickHouse / app)]
G -.->|forged, missing or oversized| X[rejected, never forwarded]
- The request path selects a source; anything else is
404. - Method, query string, encoding and body size are checked (
405,400,415,413). - Every check of the source runs against the raw body. A failure returns
401and nothing is forwarded. - The verified bytes go upstream. Transport errors and
5xxresponses retry on the next instance; the upstream's response goes back to the sender.
docker network create hookgate-demo
docker run -d --rm --name echo --network hookgate-demo ealen/echo-server # stands in for your backend
cat > hookgate.yaml <<'EOF'
sources:
- name: github
path: /github
checks:
- hmac:
header: X-Hub-Signature-256
algorithm: sha256
prefix: "sha256="
secrets: [{env: GITHUB_WEBHOOK_SECRET}]
forward:
urls: ["http://echo/"]
EOF
docker run --rm --network hookgate-demo -p 8080:8080 -e GITHUB_WEBHOOK_SECRET=demo-secret-change-me \
-v "$PWD/hookgate.yaml:/etc/hookgate/hookgate.yaml:ro" ghcr.io/mrrobertkent/hookgateSend a signed request and a forged one:
BODY='{"zen":"Keep it logically awesome."}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac demo-secret-change-me -hex | awk '{print $NF}')
curl -i localhost:8080/github -H "X-Hub-Signature-256: sha256=$SIG" --data-binary "$BODY" # 200, echoed by the backend
curl -i localhost:8080/github -H "X-Hub-Signature-256: sha256=$SIG" --data-binary "${BODY}x" # 401, never forwarded- Container:
ghcr.io/mrrobertkent/hookgate:<version>(linux/amd64, linux/arm64). Pin a version or digest in production. - Docker Compose: examples/compose runs the pinned image with a health check, a restart policy and both ports on loopback behind your reverse proxy.
- Binary: download from Releases and check it with
sha256sum -c checksums.txt. - Go:
go install github.com/mrrobertkent/hookgate/cmd/hookgate@latest
Release archives and images carry SLSA build provenance signed with Sigstore; from v0.1.1, checksums.txt and the image are also signed with cosign. All signatures are keyless, issued to the release workflow.
# archive provenance (also attached to the release as hookgate_<version>.intoto.jsonl)
gh attestation verify hookgate_<version>_linux_amd64.tar.gz --repo mrrobertkent/hookgate
# checksums signature
cosign verify-blob checksums.txt --bundle checksums.txt.sigstore.json \
--certificate-identity-regexp '^https://github.com/mrrobertkent/hookgate/.github/workflows/release.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
# image signature and provenance
cosign verify ghcr.io/mrrobertkent/hookgate:<version> \
--certificate-identity-regexp '^https://github.com/mrrobertkent/hookgate/.github/workflows/release.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
gh attestation verify oci://ghcr.io/mrrobertkent/hookgate:<version> --repo mrrobertkent/hookgatelisten: ":8080" # webhooks
admin_listen: ":9090" # /healthz, /readyz, /metrics — keep private
drain_delay: 20s # keep serving after SIGTERM while the load balancer moves away
sources:
- name: storage
path: /storage/events
max_body_bytes: 262144
checks: # all must pass
- token:
header: X-Webhook-Token
secrets: [{env: STORAGE_TOKEN}]
- hmac:
header: X-Notification-Signature
algorithm: sha256
encoding: base64
secrets:
- env: STORAGE_SIGNING_SECRET
- env: STORAGE_SIGNING_SECRET_NEXT # rotation: both verify while set
optional: true
forward:
urls: ["http://otel-collector:8088/storage/events"]
resolve_all: true # every instance behind the name, random order
pass_headers: [Content-Type]
set_headers:
Authorization: {env: UPSTREAM_TOKEN} # the upstream's own credentialSecrets are always references (env: or file:), never literals. The full reference covers every key, default and status code: docs/configuration.md.
Tip
Run hookgate validate -config hookgate.yaml in CI. It fails on unknown keys, impossible settings and unset secrets.
| Check | Algorithms | Encodings | Extras |
|---|---|---|---|
token |
constant-time compare of a header with a shared secret | — | several secrets for rotation |
hmac |
HMAC-SHA1, HMAC-SHA256, HMAC-SHA512 over the raw body | hex, base64, base64url | prefix (sha256=), separator for multi-signature headers, several secrets for rotation |
Senders: GitHub, Gitea, Bitbucket Cloud, Shopify, Linear, GitLab, iDrive e2
# GitHub
- hmac: {header: X-Hub-Signature-256, algorithm: sha256, encoding: hex, prefix: "sha256=", secrets: [{env: GITHUB_SECRET}]}
# Gitea / Forgejo
- hmac: {header: X-Gitea-Signature, algorithm: sha256, encoding: hex, secrets: [{env: GITEA_SECRET}]}
# Bitbucket Cloud
- hmac: {header: X-Hub-Signature, algorithm: sha256, encoding: hex, prefix: "sha256=", secrets: [{env: BITBUCKET_SECRET}]}
# Shopify
- hmac: {header: X-Shopify-Hmac-Sha256, algorithm: sha256, encoding: base64, secrets: [{env: SHOPIFY_SECRET}]}
# Linear (hookgate checks the signature; Linear's webhookTimestamp is not checked)
- hmac: {header: Linear-Signature, algorithm: sha256, encoding: hex, secrets: [{env: LINEAR_SECRET}]}
# GitLab secret token
- token: {header: X-Gitlab-Token, secrets: [{env: GITLAB_TOKEN}]}
# iDrive e2 event notifications (custom header + signing secret)
- token: {header: X-E2-Token, secrets: [{env: E2_TOKEN}]}
- hmac: {header: X-E2-Notification-Signature, algorithm: sha256, encoding: base64, secrets: [{env: E2_SIGNING_SECRET}]}Upstream: OpenTelemetry Collector webhookevent receiver
Each verified request becomes one log record. Give every source its own receiver and exporter so one sender's backlog never blocks another. A runnable deployment with durable per-source queues, a ClickHouse variant and a crash test: examples/otel-collector.
# otel-collector config
extensions:
bearertokenauth/hookgate:
scheme: ""
tokens: ["${env:HOOKGATE_UPSTREAM_TOKEN}"]
receivers:
webhook_event/storage:
endpoint: 0.0.0.0:8088
path: /storage/events
auth: {authenticator: bearertokenauth/hookgate}# hookgate
forward:
urls: ["http://otel-collector:8088/storage/events"]
resolve_all: true
set_headers:
Authorization: {env: HOOKGATE_UPSTREAM_TOKEN}Upstream: ClickHouse HTTP interface
One verified body becomes one row.
CREATE TABLE webhooks.github (received_at DateTime64(3) DEFAULT now64(3), body String)
ENGINE = MergeTree ORDER BY received_at;forward:
urls: ["http://clickhouse:8123/?query=INSERT%20INTO%20webhooks.github%20(body)%20FORMAT%20RawBLOB"]
set_headers:
X-ClickHouse-User: {env: CLICKHOUSE_USER} # a user that can only INSERT into this table
X-ClickHouse-Key: {env: CLICKHOUSE_PASSWORD}ClickHouse answers after the insert, so a 200 to the sender means the row is stored. Parse the JSON later with JSONExtract* or a materialized view.
Upstream: Vector http_server source
# hookgate
forward:
urls: ["http://vector:8080/github"]
pass_headers: [Content-Type, X-GitHub-*]
set_headers:
Authorization: {env: VECTOR_TOKEN}Configure the Vector http_server source with auth for the same token and acknowledgements enabled so its response waits for the sink.
- Health:
GET /healthzon the admin port (process up),GET /readyz(503while draining). The image'sHEALTHCHECKrunshookgate healthcheck. - Graceful rollout: on SIGTERM hookgate keeps serving for
drain_delay, then gives in-flight requestsshutdown_timeout. Set your orchestrator's stop grace period above their sum. - Limits: per-source body cap, server read and idle timeouts,
max_concurrentin-flight requests (503beyond it). - Metrics:
GET /metricsexposeshookgate_requests_total{source,outcome},hookgate_upstream_attempts_total{source,result},hookgate_source_ready{source},hookgate_in_flight_requestsandhookgate_draining. Alert onhookgate_source_ready == 0and onoutcome="upstream_error". - Logs: one JSON line per request with source, outcome, status, reason and timing. Secrets, signatures and bodies are never logged.
- Signatures are computed over the exact bytes received and compared in constant time (
hmac.Equal, SHA-256 digests for tokens). - Requests the signature does not cover are refused: query strings and compressed bodies by default.
- Only allowlisted headers reach the upstream; credentials the sender presented are dropped.
- Report vulnerabilities privately: SECURITY.md.
| hookgate | Reverse-proxy plugins | Pipeline receivers (OTel Collector, Vector) | Webhook command runners | Hosted gateways | |
|---|---|---|---|---|---|
| Per-sender HMAC over the raw body, hex and base64 | ✅ | Plugin-dependent, usually one scheme | Header tokens, hex HMAC at best; or scripted checks | Hex HMAC | ✅ |
| Invalid requests never reach the backend | ✅ | ✅ | Some drop invalid events after accepting them | ✅ | ✅ |
| Forwards verified bytes to any HTTP upstream with retries | ✅ | ✅, without retry by verification outcome | — (they are the backend) | Runs a command instead | ✅ |
| Self-hosted, no database | ✅ | ✅ | ✅ | ✅ | — |
| Secret rotation with overlap | ✅ | Rarely | Varies | Partly | ✅ |
hookgate does one job. It does not:
- Queue or persist. The sender gets the upstream's answer. Put a durable upstream behind it (an OpenTelemetry Collector with a persistent queue, ClickHouse, a broker) or rely on the sender's retries.
- Check timestamps or deduplicate. Schemes that sign a timestamp (Stripe, Slack, Standard Webhooks) are not supported yet.
- Verify public-key or JWT signatures (Discord, SendGrid, SNS) or answer subscription handshakes.
- Terminate TLS. Run it behind your reverse proxy.
- Transform payloads. Bodies are forwarded unchanged.
Issues and pull requests are welcome, especially new sender recipes and signature schemes. See CONTRIBUTING.md.