What you will build: A Phoenix webhook pipeline that captures request bytes, verifies a signature before the controller, and signs the buffered response. Install Phoenix with Plug (~> 1.20.3) and Jason (~> 1.0) for JSON parsing. Your sender must implement RFC 9421. This is not a verifier for every vendor-specific webhook format.
1. Configure the trusted policy
Generate an example Ed25519 key. In your application, reuse a long-lived key handle and configure trust in its public key independently.
{_public_bytes, seed} = :crypto.generate_key(:eddsa, :ed25519)
{:ok, handle} = RequestSeal.Custody.Local.new("ed25519", {:ed25519, seed})
{:ok, key} = RequestSeal.Custody.public_key(handle)Select the trusted key, required coverage, freshness, body integrity, and replay rules:
{:ok, policy} =
RequestSeal.Policy.new(%{
algorithms: ["ed25519"],
components: ~s[("@method" "@scheme" "@authority" "@path" "content-digest")],
key_resolver: fn
%{keyid: "demo-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
})Store policy under :my_app, :http_signature_policy during application startup. The pipeline retrieves it for each request. Share the public key and the "demo-key" key ID with your sender through a trusted channel.
2. Capture, parse, then verify
Application.put_env(:my_app, :http_signature_policy, policy)Capture and parse the body, then use the verifier directly. Its policy MFA returns the current policy on each request:
defmodule WebhookApp.WebhookPipeline do
use Plug.Builder
plug(RequestSeal.Plug.Capture,
origin: :connection,
max_body_bytes: 1_048_576,
read_timeout: 5_000
)
plug(Plug.Parsers,
parsers: [:json],
pass: ["*/*"],
json_decoder: Jason,
body_reader: {RequestSeal.Plug.Capture, :read_body, []}
)
plug(RequestSeal.Plug.Verify,
policy: {Application, :fetch_env!, [:my_app, :http_signature_policy]},
label: "sig",
assign: :verified,
on_reject: {:halt, 401}
)
endUse WebhookApp.WebhookPipeline before the existing endpoint parsers (or move these capture/parser plugs into the endpoint itself). Capture must run before any body read, parser, origin rewrite, or remote-IP rewrite. Use the RequestSeal reader and return its bytes unchanged; verification authenticates retained bytes, not arbitrary parser output. A partially drained reader or replaced adapter rejects. A pass-through parser may leave raw content unread for an application reader.
3. Read the result in your controller
defmodule WebhookApp.WebhookController do
use Phoenix.Controller, formats: [:json]
def create(conn, params) do
{:ok, verified} = RequestSeal.Plug.verification(conn)
json(conn, %{signature_label: verified.label, event: params["event"]})
end
endRoute POST /webhooks to this controller. Verification also assigns conn.assigns.verified; the private result remains accessible through RequestSeal.Plug.verification/1. Failure with on_reject: {:halt, 401} halts before the controller. policy: also accepts a valid policy directly or a zero-arity function returning a policy. Function and MFA results are validated; invalid results and callback failures reject the request.
Start your Phoenix endpoint with its development default port 4000. The companion
Req and Finch guide targets http://127.0.0.1:4000/webhooks;
configure :my_app, :webhook_url when your receiver uses a different address.
4. Sign a buffered response
Choose the response status, related request method/path, and body digest as the signed coverage:
response_signing = %{
label: "res",
algorithm: "ed25519",
components: ~s[("@status" "@method";req "@path";req "content-digest")],
parameters: %{
created: true,
expires_in: 60,
nonce: :random,
alg: true,
keyid: "demo-key",
tag: nil
},
digest: ["sha-256"],
field_schemas: %{}
}Call WebhookApp.ResponseSignature.sign(conn, handle, response_signing) in your endpoint after capture and before the controller sends the response:
defmodule WebhookApp.ResponseSignature do
def sign(conn, handle, signing) do
options =
RequestSeal.Plug.SignResponse.init(
sign: signing,
signer: handle,
signing_timeout: 5_000,
clock: fn -> System.system_time(:second) end,
on_failure: {:respond, 503}
)
RequestSeal.Plug.SignResponse.call(conn, options)
end
endCover the final bytes: disable server compression after signing. File and chunked bodies cannot establish retained body integrity. Pending resp_cookies prevent signing set-cookie, because Plug merges cookies after callbacks; use explicit final response headers when signing cookies. Application plugs that modify captured private state are trusted server code.
Origin and transport options
origin: :connection ignores forwarded fields. Behind a proxy, choose an explicit {:declared, "https", "api.example.com"} or {:forwarded, %{trusted_peers: [{ip_tuple, prefix}], field: :forwarded}} based on your actual deployment. Forwarded mode checks the actual peer and uses the last element; it preserves ingress facts separately. Do not derive trust from a sender-controlled forwarded header.
All three origin modes lowercase scheme and host and omit default ports per
RFC 9421 Section 2.2.3.
Coverage of @authority without @scheme or @target-uri can accept the same
signature across http://example.com, http://example.com:80,
https://example.com, and https://example.com:443 when other covered values
match. Require @scheme whenever scheme matters. @target-uri also binds the
scheme where exact target evidence is available; Plug cannot provide that evidence.
Cover @method, @scheme, @authority, @path, headers, and retained content. Plug cannot preserve the exact consumed request target: @request-target, @target-uri, and @query reject, including with a nonempty query. This also applies to response request components. Request trailers are unavailable. See RequestSeal.Plug for the measured Bandit HTTP/1 and HTTP/2 transport behavior.
Errors
| Adapter reason | Meaning and action |
|---|---|
:parser_order | Move Capture before reads/parsers; keep the replay adapter and drain readers completely. |
:not_captured | Install Capture before Verify or SignResponse. |
:unsupported_component | Choose coverage the Plug transport preserves. |
:request_rejected | Inspect the bounded core source.reason; reject the request. |
:limit | Capture halts with 413; adjust your explicit body budget if appropriate. |
:unsupported_delivery | Use a buffered response, finalized cookies, and no post-signing transformation. |
Capture's other failures halt with 400. Required verification does not authorize the action; apply your access policy afterward.
Module docs: RequestSeal.Plug, RequestSeal.Plug.Capture, RequestSeal.Plug.Verify, RequestSeal.Plug.SignResponse, RequestSeal.Adapter.Error.