Every configured source MUST declare a registered Verifier. There is no opt-in for unsigned providers; the loader fails startup if a source has no verifier or names one that is not registered.
For Render specifically (per the Standard Webhooks spec Render adopted):
- The relay computes
HMAC-SHA256(secret, "<id>.<timestamp>.<body>")over the raw values of thewebhook-idandwebhook-timestampheaders and the request body. - The
webhook-signatureheader carries a space-separated list ofv1,<base64>tokens (multiple entries support key rotation). We match against any v1 entry, base64-decoded, withhmac.Equalfor constant-time compare. - Any timestamp more than 5 minutes from the server's current UTC time is rejected (default;
skew_windowoverrides per source). - The captured
delivery_idis thewebhook-idheader value. - Secrets in the canonical Standard Webhooks
whsec_<base64>form are decoded to raw bytes before HMAC; bare strings are used verbatim.
Failed verification produces HTTP 401 with no body. Logs include only the source name and a 4-byte hex prefix of the body's sha256 — never the body, never the signature, never the secret.
- Stored in SQLite as Argon2id hashes of 32-byte URL-safe base64 plaintexts.
- Lookup re-hashes the supplied plaintext per row and compares with constant time. (O(N) per request; fine for operator-token volumes; we'd revisit only at thousands of tokens.)
hooksctl token listand/api/tokensGET return only metadata (id, name, scopes, timestamps). No path returns the plaintext after issuance.- Revoked tokens are rejected within one round-trip;
last_used_atis updated best-effort.
The special scope admin grants access to the inspector UI, /api/tokens, and /api/push-subscriptions. It does not implicitly grant subscribe access — an admin token MUST also include the source name in its scopes to subscribe.
hooksctl forward running against a logged-in profile auto-mints a kind='listener' token with ephemeral=true for the lifetime of the SSE session and revokes it on clean exit. If the CLI is killed (SIGKILL, OOM, network partition) the token row stays unrevoked locally; the server's hourly prune loop covers that case. Any ephemeral=true listener token whose last_used_at is more than 24h in the past — or whose created_at is more than 24h in the past with no last_used_at recorded — is auto-revoked. The 24h window is large enough that intentional reconnects are unaffected and short enough that an unattended hooksctl forward cannot leave a long-tail credential.
Every push delivery sets:
X-Hooks-Signature: t=<unix>,v1=<hex>where<hex>isHMAC-SHA256(secret, "<unix>.<body>").<unix>is recomputed per attempt.X-Hooks-Delivery-Id,X-Hooks-Sequence,X-Hooks-Sourcefor visibility/dedupe at the consumer.
The signing secret is stored Argon2id-hashed at rest. The dispatcher holds the plaintext in memory after registration (or rotate-secret) for HMAC computation. Consequence: after a server restart, plaintext is no longer in memory and the consumer must be re-armed via hooksctl push rotate-secret <id>. This is the documented trade-off of refusing to keep a recoverable plaintext on disk; rotate-secret takes effect on the very next attempt.
Both inbound and outbound timestamps live inside a 5-minute window relative to the verifier's clock. Outbound is per-attempt: a retried delivery gets a fresh t/v1 so consumers can verify freshness without buffering the original timestamp.
- TLS — the relay binds plain HTTP. Run it behind a TLS-terminating reverse proxy (Caddy, nginx, Cloudflare, etc.). Webhook providers will reject plain HTTP, so this is enforced by Render itself in practice.
- Replay attacks against the consumer if the consumer ignores
X-Hooks-Signature.t. Consumers MUST validate the timestamp window themselves. - At-most-once delivery — listeners may see duplicates on retry. Idempotency is the consumer's responsibility; the relay emits
delivery_idto make dedup trivial.
- Plaintext tokens, signing secrets, and provider secrets are typed as
secret.String; they redact themselves onString()/MarshalJSON. - The ingest layer logs only the source name and a body-sha256 prefix on signature mismatch.
- Test coverage asserts that no log line contains the plaintext after issuance.
The relay supports developer accounts on top of the existing token model. See docs/accounts.md for the operator-facing walkthrough; this section captures the security surface.
Listener tokens and personal access tokens (PATs) share a row schema (listener_tokens) but are routed by kind at lookup time:
kind='listener'— authorizes/subscribe/<source>and (when admin-scoped) the inspector. Cannot reach/api/me/*.kind='pat'— owned by a user; authorizes/api/me/*and the inspector. Cannot subscribe to events even when the scope set includes a source name.- Web session cookies — opaque server-side rows in
user_sessions; carryCookie: hooks_session=<id>.<plaintext>.
Splitting at the kind boundary means a stolen PAT can't silently subscribe to event traffic and a stolen listener token can't mint new credentials.
Bearer-token plaintexts (PATs, listener tokens) are hashed with Argon2id because the device-pairing window briefly exposes plaintext to the wire and the attacker partially controls the input space at issuance.
Session cookie secrets are 32 random bytes from crypto/rand and hashed with SHA-256. Argon2's slowness exists to defend against offline attacks on low-entropy passwords; a 256-bit random secret has no offline-attack surface, so the per-request CPU tax buys nothing. The session lookup still does a constant-time compare on the SHA-256 digest.
Passwords use Argon2id.
hooksctl login mints a PAT through a device-pairing flow. The plaintext is briefly persisted in device_pairings.plaintext_token between approval and the CLI's first poll — on the order of seconds, hard-capped at 15 minutes by the device-pairing TTL. The plaintext is NULL'd in a deferred update after the response handler returns. If the response write fails partway, the row stays approved_unfetched and the next poll succeeds (idempotent retry). The window is acceptable because:
- The plaintext is only fetchable by a client that knows the
device_code(one of two opaque server-generated tokens; never sent over the user-visible UX path). - After 24h post-terminal-state, the row is purged.
- This mirrors the existing reveal-once UX of
hooksctl token add.
A logged-in user could be socially engineered into approving an attacker-started pairing. Three layered mitigations:
- Narrow-by-default scope. Approval defaults to
accountonly — even a successful phish yields a PAT that can't subscribe to events or mint further tokens beyond what the user holds. The CLI explicitly opts in to broader scope via--scopesand--admin. The/devicepage surfaces the requested scopes prominently and lets the approver narrow them. - Approver context display. The
/devicepage shows the requesting client's user-agent, IP, and human-readable scope list, with the warning "Approve only if you started this on this machine." - Password re-entry. Approval requires the user to type their password into a CSRF-protected form. A live session alone is insufficient. An attacker now needs the password and the user code in the same session, which is materially harder.
POST /api/users/{id}/deactivate is atomic:
- sets
deactivated_aton the user row - revokes every PAT and listener token they own (including ephemeral ones — no special case)
- pauses every push subscription they own
The API requires a confirm=<email> body field. A last-admin guard refuses with HTTP 409 if it would leave zero active admins; the guard runs both before and inside the transaction so two admins concurrently deactivating each other can't both succeed.
Reactivation does not auto-restore tokens or unpause subscriptions. Flipping deactivated_at back to NULL is the only thing it does; the user must reissue tokens via hooksctl login and unpause their subscriptions themselves. This matches GitHub's account-disable UX. The friction is intentional — reactivation is a deliberate, observable action, not a silent restoration of prior privilege.
Every cookie-authenticated mutation is protected by two independent checks:
- Origin/Referer match. The
Originheader (orRefererifOriginis absent) must match the request host.Origin: nullis rejected. Absent both, the request is rejected. - Server-rendered double-submit token. Inspector forms embed a per-session random token in a hidden field; the server reads the matching token from a
hooks_csrfcookie and constant-time compares.
Bearer-only API calls (no cookie present) skip CSRF — they can't be cross-site-forged. Endpoints covered: /api/auth/*, /api/me/* mutations, /api/users/* mutations, /api/invites/* mutations, /api/tokens mutations, /api/push-subscriptions mutations, and the /device approval form.
In-process token-bucket-per-IP middleware (internal/ratelimit) on every authentication surface:
| Endpoint | Limits |
|---|---|
POST /api/auth/login |
5/min/IP, 30/hour/IP |
POST /api/auth/signup |
3/min/IP, 10/hour/IP |
POST /api/auth/device/start |
10/min/IP |
POST /api/auth/device/poll |
60/min/IP |
POST /api/auth/device/approve |
10/min/user |
Excess requests return HTTP 429 with Retry-After: <seconds>. Buckets live in process memory and reset on restart — acceptable for a single-process SQLite deployment. A future Redis-backed backend can swap the implementation without changing the middleware contract.
Every admin-meaningful action is recorded in the append-only audit_events table. Surfaced at /audit (admin only). Tracked actions:
invite.create,invite.revoke,invite.consumeuser.create,user.deactivate,user.reactivate,user.role_change,user.update,user.password_resettoken.transfer_owner,subscription.transfer_ownersession.create,session.deletedevice_pairing.start,device_pairing.approve,device_pairing.deny
Each row carries actor_user_id, actor_token_id, target_type, target_id, and a JSON metadata blob (e.g. counts of revoked tokens on a deactivate). The prune loop never touches this table; v1 declines to define a retention policy because growth is bounded by operator actions, not webhook traffic.
Enforced on signup and password reset by internal/users:
- Length ≥ 12 Unicode codepoints.
- Case-folded password does not contain the user's email local-part or full email.
Rejections return HTTP 400 with a generic message; the server logs the failed-policy reason (length / contains-email) but never the attempted plaintext. v1 deliberately skips HIBP-style breach-corpus checks — the goal is "block obvious mistakes", not "block all known-breached passwords".