Add optional {:ash_hooks, "~> 2.0", optional: true} with Elixir 1.20 or newer.
RequestSeal adds RFC 9421 signatures to ash_hooks deliveries and verifies inbound
requests. ash_hooks owns delivery rows, retry/backoff, Retry-After, destination
validation and pinning, secret references, and its ingress ledger. RequestSeal
starts no server, pool, or database.
This example applies RFC 9421, Content-Digest, and the public ash_hooks HTTP adapter, delivery, and provider contracts. It is an explicit application coverage choice, not a named provider profile or an external conformance vector. Configure receiver trust independently in production.
Select custody, coverage, and receiver policy
The example uses an ephemeral Ed25519 handle. Production custody belongs to the
caller. The receiver requires every covered component, freshness, and body integrity.
replay: :not_required is explicit here; select the recommended
durable replay store when each signed nonce must be spent once.
{_public, seed} = :crypto.generate_key(:eddsa, :ed25519)
{:ok, handle} = RequestSeal.Custody.Local.new("ed25519", {:ed25519, seed})
{:ok, key} = RequestSeal.Custody.public_key(handle)
components = ~s[("@method" "@scheme" "@authority" "@path" "content-digest" "content-type" "webhook-id")]
spec = %{
label: "sig",
algorithm: "ed25519",
components: components,
expires_in: 60,
keyid: "webhook-key",
digest: ["sha-256"]
}
{:ok, policy} = RequestSeal.Policy.new(%{
algorithms: ["ed25519"],
components: components,
key_resolver: fn
%{keyid: "webhook-key"} -> {:ok, %{algorithm: "ed25519", key: key}}
_ -> :error
end,
freshness: %{
clock: fn -> System.system_time(:second) end,
max_age: 60,
skew: 5,
require_expires: true
},
content: %{kind: :content, algorithms: ["sha-256"], section: :headers},
replay: :not_required
})Verify before ash_hooks ingress
Use this pipeline upstream of the consumer's ash_hooks ingress. Capture preserves
ordered header occurrences and bounded exact body bytes. Verify retains the full
result and rejects before handling. AshHooks.BodyReader receives the unchanged
bytes replayed by Capture. Plug does not establish the exact raw target; this
example covers @path, not @request-target, @target-uri, or @query.
defmodule SignedWebhookIngress do
@behaviour Plug
def init(opts), do: opts
def call(conn, opts) do
conn = RequestSeal.Plug.Capture.call(conn,
RequestSeal.Plug.Capture.init(
origin: :connection, max_body_bytes: 100_000, read_timeout: 5_000))
conn = RequestSeal.Plug.Verify.call(conn,
RequestSeal.Plug.Verify.init(
policy: opts[:policy], label: "sig", on_reject: {:halt, 401}, assign: :verified_webhook))
if conn.halted do
conn
else
{:ok, body, conn} = AshHooks.BodyReader.read_body(conn, [])
Plug.Conn.assign(conn, :webhook_body, body)
end
end
endFor proxy deployments, configure Capture's explicit trusted origin instead of accepting forwarded headers implicitly. The application chooses how to use the verification result; cryptography does not authorize a handler action. Do not claim the same required nonce again in the provider helper after Plug has spent it.
Sign deliveries
Supply your existing deliveries and endpoints resource modules and a
secret_resolver implementing ash_hooks' public contract. The endpoint still
needs its Standard Webhooks secret reference; ash_hooks adds those headers first,
then this adapter appends RFC 9421 fields over the final method, URL, headers,
and exact body. Neither the payload nor covered headers may change afterward.
config = [
deliveries: deliveries,
endpoints: endpoints,
secret_resolver: secret_resolver,
http: RequestSeal.AshHooks.Http,
http_opts: [request_seal: [spec: spec, signer: handle]]
]With an existing pending delivery row, run the real driver:
:ok = AshHooks.Delivery.run(%{
"endpoint_id" => delivery.endpoint_id,
"event_uuid" => delivery.event_uuid
}, config)The driver records success only after the receiver returns 2xx. It owns retries,
redirect refusal, 410 handling, and delivery leases. Every attempt is freshly
signed with a random nonce. Static :nonce options reject; :clock must be a
zero-arity function. For a destination containing a query, include "@query"
in the signing components and use a receiver that verifies the complete received
URI. Generic RequestSeal.Plug.Verify rejects query coverage because Plug does
not prove the original raw target; use the core verifier with target evidence
owned by the HTTP receiver. http_opts can also be an ash_hooks MFA resolver; the HTTP adapter itself
receives no endpoint or tenant identity, so choose custody in that configuration.
The adapter delegates to AshHooks.Http.Bounded with the remaining total deadline
and retains its SSRF defaults. Existing signature fields, transport-owned headers,
case collisions, duplicate signed fields, insufficient coverage, and digest conflicts
reject before sending. Signing failures return
{:error, {:terminal, :request_seal_signing_failed}}; transport errors and exceptions
retain the bounded transport's classification. The terminal tag identifies an
adapter signing failure to callers. ash_hooks 2.0 treats returned adapter errors
other than :unsafe_destination as retryable; callers must account for that driver
behavior when configuring their delivery policy.
Delegate from a Provider
A consumer's AshHooks.Provider.verify_signature/3 can call
RequestSeal.AshHooks.verify_signature/4 with its raw body, provider context,
nonempty binary key reference, and trusted policy source. For a received request
whose exact body, header map headers, and absolute destination are supplied:
context = %{
method: "POST",
request_uri: destination,
headers: headers,
signature: headers["signature"],
tenant: nil
}
:ok = RequestSeal.AshHooks.verify_signature(body, context, "webhook-key",
policy: fn nil, "webhook-key" -> policy end, label: "sig")ash_hooks currently passes a headers map and accepts only :ok or its signature
errors. The helper cannot restore lost duplicate lines/order or return verified
facts through that callback. The upstream Plug pattern preserves those facts and
ordered occurrences. An absent key reference returns :no_webhook_secret;
verification or policy failures return :invalid_signature. Configure provider
key-reference resolution independently of the inbound request. A policy callback
receives both the provider context's tenant and the secret reference; resolve keys
within that tenant even when another tenant uses the same reference.