Powered by AppSignal & Oban Pro

Verify signed webhooks in a Plug pipeline

livebooks/plug-webhook.livemd

Verify signed webhooks in a Plug pipeline

This notebook builds a webhook receiver with Plug.Router and RequestSeal's Plug integration, then sends it signed, unsigned, and altered requests through Plug.Test. Phoenix endpoints use the same plugs. The requests go straight into the pipeline in this process; no server, port, or network is involved beyond the Hex install.

Mix.install([{:request_seal, "~> 0.4.1"}, {:plug, "~> 1.20.3"}])

Configure the sender and the policy

The sender holds the private key. The receiver's policy trusts only the sender's public key and requires the body digest to be signed.

{_public_bytes, seed} = :crypto.generate_key(:eddsa, :ed25519)
{:ok, sender} = RequestSeal.Custody.Local.new("ed25519", {:ed25519, seed})
{:ok, sender_key} = RequestSeal.Custody.public_key(sender)

components = ~s[("@method" "@scheme" "@authority" "@path" "content-digest")]

{:ok, policy} =
  RequestSeal.Policy.new(%{
    algorithms: ["ed25519"],
    components: components,
    key_resolver: fn
      %{keyid: "orders-service"} -> {:ok, %{algorithm: "ed25519", key: sender_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
  })

Application.put_env(:webhook_notebook, :signature_policy, policy)
IO.puts("Policy configured")

Build the receiver

RequestSeal.Plug.Capture keeps the exact body bytes before Plug.Parsers consumes them, and Plug.Parsers reads through Capture's body reader. RequestSeal.Plug.Verify then rejects anything that fails the policy before the route runs. Elixir's built-in JSON module decodes the body.

defmodule ShipmentWebhook do
  use Plug.Router

  plug(RequestSeal.Plug.Capture,
    origin: :connection,
    max_body_bytes: 65_536,
    read_timeout: 5_000
  )

  plug(Plug.Parsers,
    parsers: [:json],
    pass: ["*/*"],
    json_decoder: JSON,
    body_reader: {RequestSeal.Plug.Capture, :read_body, []}
  )

  plug(RequestSeal.Plug.Verify,
    policy: {Application, :fetch_env!, [:webhook_notebook, :signature_policy]},
    label: "sig",
    on_reject: {:halt, 401}
  )

  plug(:match)
  plug(:dispatch)

  post "/hooks/shipments" do
    {:ok, verified} = RequestSeal.Plug.verification(conn)
    send_resp(conn, 200, "#{verified.signature.crypto}: #{conn.body_params["status"]}")
  end

  match _ do
    send_resp(conn, 404, "not found")
  end
end

IO.puts("Receiver compiled")

Send a signed request

The sender signs with the core API. Its headers, including Content-Digest, Signature-Input, and Signature, go onto a Plug.Test connection with the same body.

url = "https://hooks.example.com/hooks/shipments"
event = ~s({"shipment":"SHP-2207","status":"delivered"})

{:ok, request} =
  RequestSeal.Message.request("POST", url, [{"content-type", "application/json"}], event,
    digest: ["sha-256"]
  )

{:ok, signed} =
  RequestSeal.sign(
    request,
    %{
      label: "sig",
      algorithm: "ed25519",
      components: components,
      expires_in: 60,
      keyid: "orders-service"
    },
    sender
  )

deliver = fn headers, body ->
  conn =
    Enum.reduce(headers, Plug.Test.conn(:post, url, body), fn {name, value}, conn ->
      Plug.Conn.put_req_header(conn, String.downcase(name), value)
    end)

  ShipmentWebhook.call(conn, ShipmentWebhook.init([]))
end

signed_headers = Enum.map(signed.fields, &{&1.name, &1.value})
accepted = deliver.(signed_headers, event)

200 = accepted.status
"valid: delivered" = accepted.resp_body
IO.puts("Signed request: #{accepted.status} #{accepted.resp_body}")

Reject unsigned and altered requests

unsigned = deliver.([{"content-type", "application/json"}], event)
401 = unsigned.status

altered = deliver.(signed_headers, ~s({"shipment":"SHP-2207","status":"returned"}))
401 = altered.status

# No verification result is available for a rejected request.
:error = RequestSeal.Plug.verification(altered)

# Verify keeps the bounded rejection in private connection state for logging.
{:error, error} = altered.private.request_seal.verification
:request_rejected = error.reason
%{reason: :digest_mismatch, layer: :content} = error.source

IO.puts("Unsigned: #{unsigned.status}; altered body: #{altered.status}")

RequestSeal.Plug.verification/1 returns only successful results. A rejection is a RequestSeal.Adapter.Error with reason :request_rejected in conn.private[:request_seal].verification; its source is the core RequestSeal.Error with the precise reason and layer. Neither contains request bytes or key IDs.

Clean up

:ok = RequestSeal.Custody.Local.release(sender)
Application.delete_env(:webhook_notebook, :signature_policy)
IO.puts("Sender key released")

Next: Phoenix and Plug covers trusted proxies, response signing, and the Phoenix endpoint order; the README's webhook example runs the same receiver over real HTTP with Bandit and Req.