Verifying deliveries

Anyone who knows an inbox's ingest URL can send it a request. A verifier is what separates a genuine delivery from a forged one: it checks a signature (or a shared token) against the exact bytes that arrived, using a secret only the sender and the inbox know.

Each inbox has:

Setting Meaning
verifier Which scheme to use (the table below).
verifier_config Non-secret parameters, as a JSON object. Unknown keys are an error, so a typo is caught instead of silently ignored.
verifier secret The shared secret. Stored encrypted with the master key; never returned by any API.
on_verify_fail What to do with a delivery that fails: reject (answer 401, store nothing) or store (keep it, marked unverified).

The presets

Verifier Sender What is signed Timestamp Delivery id Event type
stripe Stripe <t>.<body> yes, in the header id in the body type in the body
github GitHub the body no X-GitHub-Delivery header (not signed) X-GitHub-Event header (not signed)
slack Slack v0:<timestamp>:<body> yes event_id in the body event.type or type in the body
standardwebhooks Standard Webhooks, Svix, and compatible senders <id>.<timestamp>.<body> yes webhook-id header (signed) type in the body
hmac anything that signs with an HMAC configurable optional optional header (never signed) from the inbox's event_type_pointer, for verified deliveries only
token anything that can send a header not a signature: the header must equal the secret no none from event_type_pointer
none anyone nothing no none none

Every comparison of a signature, token, or secret is constant-time.

Common rules

stripe

Header: Stripe-Signature: t=<unix>,v1=<hex>[,v1=<hex>...]. The secret is the endpoint's signing secret (whsec_...), used exactly as written. Any one matching v1 is enough, which lets Stripe rotate secrets.

Config: {"tolerance_seconds": 300} (optional).

github

Header: X-Hub-Signature-256: sha256=<hex>. The secret is the webhook secret. The legacy X-Hub-Signature (SHA-1) header is not used.

GitHub signs only the body. X-GitHub-Delivery and X-GitHub-Event are metadata, not proof. Someone holding one captured, validly signed body can replay it at any time, under any delivery id and with any event type, and the replay is stored as verified. GitHub provides no timestamp that would let a receiver stop that. Treat the delivery id as a retry de-duplicator, not as replay protection (and hookinlet does not trust it to be one: see duplicates), and do not let anything with consequences (a deploy, a payment) act on the event type alone.

slack

Headers: X-Slack-Request-Timestamp and X-Slack-Signature: v0=<hex>. The secret is the app's signing secret.

When Slack sends a url_verification request, hookinlet answers it with the challenge (as plain text) after the signature checks out, and does not store it. A forged handshake gets a 401 and no challenge. The challenge is returned as text/plain with X-Content-Type-Options: nosniff, and one longer than 256 characters is not echoed at all (the request is stored like any other).

Config: {"tolerance_seconds": 300} (optional).

standardwebhooks

Headers: webhook-id, webhook-timestamp, and webhook-signature, which holds one or more space-separated v1,<base64> values. The secret is written whsec_<base64>; hookinlet decodes the base64 part to get the key. Signature versions other than v1 are ignored. A webhook-id that contains a . is rejected, because the signed text is <id>.<timestamp>.<body> and a dot in the id would let the same bytes be read as a different split.

Config: {"tolerance_seconds": 300} (optional).

hmac (generic)

For senders that sign with an HMAC but are not one of the presets.

Key Meaning Default
header Header carrying the signature. Required.
algorithm sha1, sha256, sha384, or sha512. sha256
encoding hex, base64, or base64url (padding optional). hex
prefix Literal text before the signature, such as sha256=. It must be present. none
timestamp_header Header with a Unix timestamp in seconds. Enables the tolerance check. none
payload_template What is signed. {body} (exactly once) and {timestamp}; any other brace is an error. With timestamp_header it must include {timestamp}, and {timestamp} and {body} must be separated by at least one character that is not a digit. {body}, or {timestamp}.{body} with a timestamp header
tolerance_seconds Only with timestamp_header. 300
delivery_id_header Header whose value deduplicates retries. It is never authenticated, so it is only a retry de-duplicator. none

The template is parsed once when the inbox is loaded, so text in a delivery that happens to look like {timestamp} is never expanded. A configuration that would make the signed bytes ambiguous, or that would check a timestamp the signature does not cover, is rejected when the inbox is loaded, with an explanation.

Examples:

{"header": "X-Shopify-Hmac-Sha256", "encoding": "base64"}
{"header": "X-Hub-Signature", "algorithm": "sha1", "prefix": "sha1="}
{"header": "X-Signature", "timestamp_header": "X-Timestamp",
 "payload_template": "v0:{timestamp}:{body}", "tolerance_seconds": 120}

SHA-1 is offered only because some senders offer nothing else. HMAC-SHA-1 is still a sound message authentication code, but prefer SHA-256 when you can choose.

Not supported: schemes that sign the request URL or sorted form fields (such as Twilio's) and asymmetric signatures (such as Ed25519).

token

The sender puts the shared secret in a header. This is authentication, not integrity: anyone who observes one request can replay the token. Use it only over TLS and only for senders that offer nothing better. The header is never stored with the delivery.

Config: {"header": "X-Inbox-Token"} (optional; that is the default).

none

Accepts every delivery, and says so: what it accepts is stored with verified: false, and nothing the sender claimed is trusted (no delivery id, no event type, no deduplication). Use it only for an inbox that deliberately takes anything, or while you are wiring up a sender and want to see what it sends. It has to be asked for by name: creating an inbox without a verifier is an error.

When verification fails

on_verify_fail Result
reject (default) 401 with {"error": {"code": "verification_failed", "message": "webhook verification failed"}}. Nothing is stored.
store The delivery is stored with verified: false and a verify_error, and the sender gets 200 (so it stops retrying).

The sender is told only that verification failed. The specific reason is a short fixed phrase (signature mismatch, timestamp outside tolerance, missing X-Hub-Signature-256 header, ...) that goes to the server log and, in store mode, to the message's verify_error. It is kept out of the response because it can name the header and scheme an inbox expects, which is not for whoever happens to hold the ingest key. It never contains the signature, the secret, or any part of the request.

An unverified delivery is stored as received, but nothing the sender claimed is trusted: its delivery id and event type are left empty (the inbox's event_type_pointer is not applied to it either). This matters for two reasons:

store mode has a cost. Anyone who knows the ingest key can fill an inbox with unverified messages, and once count-based retention applies they push genuine ones out. Keep the default reject unless you are debugging a sender, and use a BeforeIngest hook to rate limit. A cap on unverified messages is on the roadmap.

Trying it out

Create an inbox (the verifier secret is read from the environment, never from a flag, because flags show up in process listings). This writes into the data directory, so run it in the shell where you started the server in the README, which has its data directory and master key. The first line of the block keeps the key that is set and makes one only when none is (a different key would seal the secret so that the running server cannot open it):

export HOOKINLET_MASTER_KEY=${HOOKINLET_MASTER_KEY:-$(openssl rand -base64 32)}   # keep this; it encrypts stored secrets
HOOKINLET_VERIFIER_SECRET='my-shared-secret' \
  hookinlet inbox create --slug demo --ingest-key demo --verifier hmac \
  --verifier-config '{"header":"X-Signature","prefix":"sha256="}'

Send a signed request:

body='{"hello":"world"}'
sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac 'my-shared-secret' -hex | sed 's/^.* //')
curl -i -X POST http://localhost:8080/in/demo \
  -H "X-Signature: sha256=$sig" -d "$body"
# HTTP/1.1 200 OK
# {"seq":1}

Change one character of $body and send the same signature: you get a 401.

Trying it with Stripe

Stripe's CLI forwards test events with a real signature. This is the same loop against Stripe (it needs a Stripe account, so it is shown, not run by the tests):

# the signing secret Stripe will use, whsec_…
secret=$(stripe listen --print-secret)

# create the inbox with that secret
curl -s -X POST localhost:8080/v1/admin/inboxes \
  -H "Authorization: Bearer $HOOKINLET_ADMIN_TOKEN" -H 'Content-Type: application/json' -d @- > created.json <<EOF
{"slug": "stripe", "verifier": "stripe", "verifier_secret": "$secret"}
EOF
# keep the agent token in HOOKINLET_AGENT_TOKEN, as in the README, and forward Stripe's test events to the inbox
INGEST=$(sed -n 's/.*"ingest_path":"\([^"]*\)".*/\1/p' created.json)
stripe listen --forward-to localhost:8080$INGEST &

# send some events, then read the inbox
stripe trigger invoice.paid
curl -s 'localhost:8080/v1/inboxes/stripe/messages?after=0&fields=summary' \
  -H "Authorization: Bearer $HOOKINLET_AGENT_TOKEN" | jq -c '.messages[] | {seq, event_type, verified}'
{"seq":1,"event_type":"customer.created","verified":true}
…
{"seq":5,"event_type":"invoice.paid","verified":true}

Duplicate deliveries

A verified delivery with a delivery id is a duplicate of an earlier one, and is answered with the earlier seq and not stored again, when the earlier one has the same id and the id is one the signature covers (stripe, slack, standardwebhooks).

For github and hmac the id comes from a header the signature does not cover, so the id alone proves nothing: whoever captured one signed request could send it again with an id of their choosing, or claim the ids of deliveries that have not happened yet, and the genuine ones would be taken for duplicates and dropped. For those, a duplicate is the same id and the same body. The same id with another body is stored as a new message, and keeps its id.

Secrets are checked when they are set

A secret the verifier cannot use is refused when you set it, not discovered at the first delivery (which a sender such as GitHub does not retry): a Standard Webhooks secret has to be whsec_ followed by base64. Changing an inbox's verifier removes its secret, because a secret set for one scheme must not become a credential of another; the answer says so, and warns that the new verifier needs one.

Adding your own verifier

An application that embeds the server can register more schemes:

reg := verify.Default()
err := reg.Register("acme-v2", func(config json.RawMessage) (verify.Verifier, error) {
    // parse config; return an error that wraps verify.ErrConfig if it is invalid
    return acmeVerifier{}, nil
})
srv, _ := server.New(server.Options{ /* ... */ Verifiers: reg })

A Verifier returns an error satisfying verify.IsFailure when a delivery does not verify, and an error wrapping verify.ErrConfig when the verifier cannot run at all. If its credential travels in a header, it should also implement verify.HeaderRedactor so that header is never stored. Compare secrets and signatures with hmac.Equal or crypto/subtle, never with == or bytes.Equal.

From docs/verifiers.md at v0.1.1. Markdown · View on GitHub