Verify webhook signatures in Phoenix and Plug

Copy Markdown View Source

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}
  )
end

Use 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
end

Route 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
end

Cover 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 reasonMeaning and action
:parser_orderMove Capture before reads/parsers; keep the replay adapter and drain readers completely.
:not_capturedInstall Capture before Verify or SignResponse.
:unsupported_componentChoose coverage the Plug transport preserves.
:request_rejectedInspect the bounded core source.reason; reject the request.
:limitCapture halts with 413; adjust your explicit body budget if appropriate.
:unsupported_deliveryUse 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.