Security¶
Hookaido provides layered security across ingress authentication, transport encryption, secret management, API access control, and egress protection.
Inbound Authentication (Ingress)¶
HMAC Signature Verification¶
Verify webhook signatures using HMAC-SHA256 with replay protection:
Full control via block form:
/webhooks/github {
auth hmac {
secret env:HOOKAIDO_GITHUB_SECRET
signature_header "X-Hub-Signature-256"
timestamp_header "X-Timestamp"
nonce_header "X-Nonce"
tolerance 5m
}
}
Replay protection: The timestamp header is checked against the tolerance window. The nonce header (when configured) provides additional replay defense.
A nonce is claimed when the signature verifies and becomes permanent once the request is durably enqueued. If the queue rejects the message (503) or the request is refused after verification, the claim is released, so the sender's identical signed retry — the normal reaction to backpressure — is accepted rather than answered with 401 for the rest of the tolerance window. A replay arriving while the first request is still in flight is rejected either way. Claims survive config reloads, including those triggered by Admin API managed-endpoint mutations.
Secret rotation: With secret_ref, verification tries all secrets valid at the request timestamp (from the signed timestamp header), allowing overlapping key rotation with zero downtime.
Providers¶
Provider-compatible HMAC verification short-circuits the canonical format and instead uses the wire format of a specific webhook provider:
/webhooks/github { auth hmac { provider github; secret env:GH_SECRET } }
/webhooks/gitea { auth hmac { provider gitea; secret env:GITEA_SECRET } }
/webhooks/stripe { auth hmac { provider stripe; secret env:STRIPE_SECRET } }
/webhooks/cituro { auth hmac { provider cituro; secret env:CITURO_SECRET } }
| Provider | Header | Signed payload | Replay protection |
|---|---|---|---|
github |
X-Hub-Signature-256: sha256=<hex> |
body |
none (GitHub omits timestamp) |
gitea |
X-Gitea-Signature: <hex> |
body |
none |
stripe |
Stripe-Signature: t=<ts>,v1=<hex>[,v0=<hex>...] |
<ts>.<body> |
5 min fixed tolerance |
cituro |
X-CITURO-SIGNATURE: t=<ts>,s=<hex> |
<ts>.<body> |
5 min fixed tolerance |
stripe and cituro share the same signature scheme (timestamped HMAC-SHA256
over <ts>.<body>) — cituro is effectively an alias with a different
header name (X-CITURO-SIGNATURE) and signature tag (s instead of v1).
Both accept multiple comma-separated <tag>=<hex> pairs in the header; any
matching signature verifies the request (useful for Stripe's v0/v1 rotation).
Provider mode is mutually exclusive with signature_header, timestamp_header,
nonce_header, and tolerance — use the canonical block form (without
provider) if you need to customise those.
Basic Auth¶
Credentials are held as SHA-256 digests and compared in constant time, and an unknown username runs the same comparison against a decoy digest — so neither which usernames exist nor how long the configured password is can be read off response timing.
Basic-auth credentials are compared literally — the env: / file: / vault: /
raw: reference syntax used by auth token, auth hmac and secret blocks is
not resolved here. Use the {env.NAME} placeholder form, which is expanded
at compile time. Reference syntax is rejected at compile time; see
Ingress → Basic Auth.
Forward Auth¶
Delegate to an external auth service:
Forward auth fails closed: transport errors, timeouts, and non-2xx/401/403 responses all result in 503.
TLS and mTLS¶
Every listener (ingress, Pull API, Admin API) supports TLS with optional mutual TLS:
ingress {
listen :8080
tls {
cert_file /path/to/cert.pem
key_file /path/to/key.pem
client_ca /path/to/ca.pem # enables mTLS
client_auth require_and_verify # optional
}
}
pull_api {
listen :9443
tls {
cert_file /path/to/pull-cert.pem
key_file /path/to/pull-key.pem
client_ca /path/to/pull-ca.pem
}
}
Client Auth Modes¶
| Value | Description |
|---|---|
none |
No client certificate requested |
request |
Request certificate, don't require it |
require |
Require certificate (default when client_ca is set) |
verify_if_given |
Verify certificate only if provided |
require_and_verify |
Require and verify a valid client certificate |
Production TLS: Reverse Proxy Recommended¶
Hookaido does not include automatic certificate provisioning (ACME / Let's Encrypt). For production deployments, terminate TLS at a reverse proxy or cloud load balancer and forward plain HTTP to Hookaido's ingress listener:
| Approach | Example |
|---|---|
| Reverse proxy | Caddy, nginx, Traefik (automatic ACME) |
| Cloud LB | AWS ALB/NLB, GCP HTTPS LB, Azure App Gateway |
| Service mesh | Istio, Linkerd sidecar TLS |
Use Hookaido's built-in tls { cert_file, key_file } when you need:
- mTLS between Hookaido and internal callers (Pull API, Admin API).
- Direct TLS with certificates from a private CA or cert-manager.
Tip: In the common DMZ-pull deployment, the ingress listener sits behind a TLS-terminating reverse proxy while the Pull API and Admin API use mTLS or stay on localhost.
Secret Management¶
Secrets should never appear as plaintext in config files. Hookaido supports several reference types:
| Reference | Example | Description |
|---|---|---|
env: |
env:MY_SECRET |
Environment variable |
file: |
file:/run/secrets/key |
File content |
vault: |
vault:secret/data/hookaido#token |
HashiCorp Vault (or compatible HTTP API) |
raw: |
raw:literal-value |
Inline value (dev only) |
For vault: refs, configure access via environment variables:
HOOKAIDO_VAULT_ADDR(required): Vault base URL, e.g.https://vault.example.com:8200HOOKAIDO_VAULT_TOKEN(required): Vault token used asX-Vault-TokenHOOKAIDO_VAULT_NAMESPACE(optional): sent asX-Vault-NamespaceHOOKAIDO_VAULT_TIMEOUT(optional): request timeout (default5s)HOOKAIDO_VAULT_CACERT(optional): CA bundle path for TLS verificationHOOKAIDO_VAULT_CLIENT_CERT+HOOKAIDO_VAULT_CLIENT_KEY(optional): mTLS client cert/key pairHOOKAIDO_VAULT_INSECURE_SKIP_VERIFY(optional):on|off/true|false/1|0
Named Secrets with Rotation¶
Define secrets with validity windows in a secrets block:
secrets {
secret "hmac-v1" {
value env:HMAC_SECRET_V1
valid_from "2026-01-01T00:00:00Z"
valid_until "2026-07-01T00:00:00Z"
}
secret "hmac-v2" {
value env:HMAC_SECRET_V2
valid_from "2026-06-01T00:00:00Z"
}
}
valid_fromis inclusive,valid_untilis exclusive.- Omit
valid_untilfor keys that don't expire. - Referenced via
secret_ref "hmac-v1"in auth and signing blocks.
Rotation semantics:
- Signing selects the newest (or oldest) valid secret at signing time.
- Verification tries all secrets valid at the request timestamp — not wall-clock time — enabling safe rotation with overlapping windows.
Runtime Rotation via Admin API¶
For HMAC verification secrets that the upstream issuer rotates at runtime (Cituro's roll-secret, Stripe webhook-endpoint rotation, etc.), declare the pool as runtime true:
secrets {
secret "cituro" {
runtime true
max_versions 16
# optional bootstrap: seeded once if the DB pool is empty
value env:CITURO_BOOTSTRAP_SECRET
valid_from "2026-04-21T00:00:00Z"
}
}
The issuer service (e.g. soapNEO) then pushes fresh secrets via the admin API:
POST /admin/secrets/cituro # add new secret during overlap window
DELETE /admin/secrets/cituro/sec_<old> # after cut-over
Requirements:
HOOKAIDO_SECRET_ENCRYPTION_KEY(32 bytes, base64) must be exported. Runtime-added secrets are AES-256-GCM sealed before they are written to the backing store (SQLiteruntime_secretstable, or Postgres equivalent). The key is only in memory — the DB alone is not sufficient to decrypt.- Backing store must be SQLite or Postgres. Memory-only deployments accept the writes but lose them on restart (a warning is logged at startup).
- The admin token already protects the endpoint. Keep the admin listener on an internal interface.
Operational notes:
- Push before cut-over: the issuer must POST the secret to Hookaido before the upstream provider begins issuing webhooks signed with it. Otherwise the
verifyStripe/verifyCituro5-minute tolerance window will silently drop early requests. - Backup policy: the
runtime_secretstable contains sealed ciphertext; withoutHOOKAIDO_SECRET_ENCRYPTION_KEYit is unrecoverable. Store the key separately from the DB backup. - Key rotation: rotating
HOOKAIDO_SECRET_ENCRYPTION_KEYinvalidates all persisted runtime secrets. Plan a two-phase rotation (new key → issuer re-pushes → old key retired) — no automated re-wrap in v1. - Overlap windows: use
not_before/not_afteron the POST body to describe the rotation overlap.Set.ValidAtalready filters by time, so both secrets can be live for the cut-over window. - Expired-version GC: a background sweeper runs every 5 minutes (plus once at startup) and removes versions whose
not_afteris in the past, both from the in-memory pool and from the persistedruntime_secretstable. Without it, short overlap windows would causeGET /admin/secrets/<name>and the DB to grow unboundedly since the per-POSTopportunistic prune only fires when the pool is atmax_versions. Each sweep emits asecret_gc_prunedlog line per affected pool and incrementshookaido_runtime_secret_gc_pruned_total{pool="<name>"}. The interval is not user-configurable in v1.
A pool with no valid version¶
A pool that holds no version valid right now is not a degraded state — every
route whose secret_ref names it answers 401 to every sender, and the process
otherwise looks healthy. This happens when a deploy lands on a fresh volume
before the issuer's next push, or when the last version's not_after passes
without a replacement.
Four surfaces report it, so the failure cannot only be visible in the sender's delivery log:
| Surface | Signal |
|---|---|
| Metrics | hookaido_runtime_secret_pool_versions{pool,state="valid"} == 0, with hookaido_runtime_secret_pool_exhaustion_seconds{pool} counting down to it |
| Health | GET /healthz?details=1 → diagnostics.runtime_secrets.pools_without_valid_version (plus per-pool counts and deadlines) |
| Runtime log | runtime_secret_pool_without_valid_version (WARN) on the sweep that observes the transition, runtime_secret_pool_valid_version_restored (INFO) on recovery; route_secret_ref_without_valid_version (WARN) at startup and on every reload, naming the affected route |
| Request path | hookaido_ingress_auth_rejected_total{route,reason="no_valid_secret"} — separated from signature_mismatch and timestamp_out_of_window, which are the sender's problem rather than yours |
The pool gauges cover static secret blocks too: a static pool whose single
version has lapsed rejects requests exactly the same way.
Alert on exhaustion_seconds, not on next_expiry_seconds — the latter drops to
near zero on every handover of a healthy overlapping rotation.
API Access Control¶
Pull API¶
Token-based authentication is required:
Per-route tokens can override the global allowlist:
Admin API¶
Default bind: 127.0.0.1:2019 (localhost only). Optional token auth:
The Admin API should only be exposed over a secure channel. Use TLS + token auth or mTLS when exposing beyond localhost.
Audit Trail¶
All Admin API mutations require X-Hookaido-Audit-Reason and emit structured audit log events with:
- Timestamp, actor, reason, request ID
- Operation details (state transitions, queue counts, config changes)
Optional stricter audit requirements via defaults.publish_policy:
require_actor on— requireX-Hookaido-Audit-Actorrequire_request_id on— requireX-Request-ID
Scoped managed operations can additionally restrict actors:
actor_allow "ci-bot"— only listed actors allowedactor_prefix "deploy-"— actors must match prefix
Egress Protection (SSRF)¶
All outbound deliveries (push mode) enforce SSRF-safe defaults:
defaults {
egress {
allow "*.internal.example.com"
deny "169.254.0.0/16" # AWS metadata
deny "10.0.0.0/8" # private ranges
https_only on # only HTTPS targets (default)
redirects off # don't follow redirects (default)
dns_rebind_protection on # block DNS rebinding (default)
}
}
Evaluation order: Deny rules first, then allowlist (if configured, target must match).
Matching across multiple addresses. A hostname commonly resolves to several addresses, and the dialer may use any of them:
- Deny and
dns_rebind_protectionare satisfied by one bad address — a single denied or non-routable address refuses the target. - Allow CIDR rules require all resolved addresses to be covered. Any-match would be fail-open here: a host answering with one in-range address plus
169.254.169.254would otherwise clear the allowlist and could then be connected on the metadata address. - Allow host rules stay any-match, since the request carries exactly one host.
Wildcard support:
*— matches any host*.example.com— matches subdomains only (not the apex)
Ranges blocked by dns_rebind_protection. Beyond loopback, link-local, multicast and the unspecified address, delivery is refused to 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10 (CGNAT, hosts Alibaba's metadata endpoint), 127.0.0.0/8, 169.254.0.0/16 (AWS/GCP/Azure metadata), 172.16.0.0/12, 192.0.0.0/24 (hosts Oracle's metadata endpoint), 192.168.0.0/16, 198.18.0.0/15, 240.0.0.0/4, ::/96, 64:ff9b::/96 (NAT64, reaches 169.254.169.254 by translation) and fc00::/7. IPv4-mapped IPv6 forms such as ::ffff:169.254.169.254 are unmapped before the check, so they cannot be used to slip past it.
Publish Policy¶
Control who can publish messages programmatically via the Admin API:
defaults {
publish_policy {
direct on # allow POST /messages/publish
managed on # allow endpoint-scoped publish
allow_pull_routes on # allow publish to pull-mode routes
allow_deliver_routes on # allow publish to push-mode routes
require_actor off # require X-Hookaido-Audit-Actor
require_request_id off # require X-Request-ID
fail_closed off # fail closed when context unavailable
actor_allow "ci-bot" # explicit actor allowlist
actor_prefix "deploy-" # actor prefix match
}
}
Per-route publish control:
/webhooks/github {
publish off # block all manual publish to this route
# or selectively:
publish {
direct off # block global direct publish path
managed off # block endpoint-scoped managed publish path
}
}
Security Checklist¶
- [ ] Use HMAC or forward auth on all ingress routes
- [ ] Never put secrets as plaintext in the Hookaidofile — use
env:,file:, orvault:refs - [ ] Enable TLS on all externally accessible listeners (ingress, Pull API)
- [ ] Admin API: keep on localhost or protect with mTLS + token auth
- [ ] Configure
egress.denyfor internal network ranges - [ ] Enable audit requirements (
require_actor,require_request_id) in production - [ ] Rotate secrets using named
secret_refentries with overlapping validity windows - [ ] Review
publish_policydefaults — restrict by actor/route mode as needed