RequestSeal

RequestSeal signs and verifies HTTP requests and responses in Elixir with RFC 9421 HTTP Message Signatures, including Web Bot Auth for AI agents and crawlers. Integrations support Req, Finch, and Ash; Phoenix uses the Plug integration; JOSE supports JWS signing and JWE encryption.

Use it to:

Concepts in one minute

Hello world

The examples cover @scheme as well as @authority: default HTTP port 80 and HTTPS port 443 are omitted from the authority, so authority alone does not bind the scheme. See coverage choices.

{_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")]
{:ok, message} = RequestSeal.Message.request("GET", "https://example.com/", [], nil)
spec = %{label: "sig", algorithm: "ed25519", components: components, expires_in: 60}
{:ok, signed} = RequestSeal.sign(message, spec, handle)
resolve = fn _ -> {:ok, %{algorithm: "ed25519", key: key}} end
clock = fn -> System.system_time(:second) end

{:ok, policy} =
  RequestSeal.Policy.new(%{
    algorithms: ["ed25519"],
    components: components,
    key_resolver: resolve,
    freshness: %{clock: clock, max_age: 60, skew: 5, require_expires: true},
    content: :not_required,
    replay: :not_required
  })

{:ok, verification} = RequestSeal.verify(signed, policy, label: "sig")
IO.inspect(verification.signature.crypto)

Expected output:

:valid

Installation

Add this entry to your mix.exs dependency list, then run mix deps.get:

{:request_seal, "~> 0.3.1"}

Install RequestSeal 0.3.1 from Hex. Commit your application's mix.lock for reproducible builds.

Add optional dependencies for the integrations you use. The table is a reference; the complete webhook dependency list follows.

Dependency Requirement Use
:plug ~> 1.20.3 Plug pipelines, also used by Phoenix
:bandit ~> 1.12.5 To run the example receiver
:phoenix ~> 1.8.15 Only for the Phoenix controller example
:req ~> 0.7.4 Req signing and response verification; also add Finch
:finch >= 0.23.0 and < 0.25.0 Finch transport and caller-started pools
:ash >= 3.34.3 and < 4.0.0 Ash scope protocol and authorization-enabled actions
:ash_onetime ~> 1.5 Recommended durable replay; Elixir 1.20+
:ash_hooks ~> 2.0 Webhook signing and verification; Elixir 1.20+
:postgrex ~> 0.22.4 PostgreSQL replay storage using your existing connection

For both HTTP scripts below, paste this complete list into your deps/0 function. Jason decodes the webhook JSON:

[
  {:request_seal, "~> 0.3.1"},
  {:req, "~> 0.7.4"},
  {:finch, ">= 0.23.0 and < 0.25.0"},
  {:plug, "~> 1.20.3"},
  {:bandit, "~> 1.12.5"},
  {:jason, "~> 1.0"}
]

Send and receive a signed webhook

Save this entire script as webhook.exs in a fresh mix new project with the dependencies above, run mix deps.get, then mix run webhook.exs. It starts a receiver on an available local port, sends a signed request, and checks unsigned rejection.

In a real deployment, the receiver gets the sender's public key out of band or from a trusted JWKS URL or key directory; see key discovery.

defmodule WebhookReceiver do
  use Plug.Router

  # Capture keeps the signed body bytes before Parsers consumes them.
  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!, [:webhook_demo, :signature_policy]},
    label: "sig",
    on_reject: {:halt, 401}
  )

  plug(:match)
  plug(:dispatch)

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

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

# The sender owns the private key; the receiver gets only its public key.
{_public, webhook_seed} = :crypto.generate_key(:eddsa, :ed25519)
{:ok, webhook_handle} = RequestSeal.Custody.Local.new("ed25519", {:ed25519, webhook_seed})
{:ok, webhook_key} = RequestSeal.Custody.public_key(webhook_handle)
webhook_components = ~s[("@method" "@scheme" "@authority" "@path" "content-digest")]

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

webhook_signing = %{
  label: "sig",
  algorithm: "ed25519",
  components: webhook_components,
  expires_in: 60,
  keyid: "sender-key",
  digest: ["sha-256"]
}

Application.put_env(:webhook_demo, :signature_policy, webhook_policy)
{:ok, webhook_apps} = Application.ensure_all_started(:req)
{:ok, webhook_pool} = Finch.start_link(name: WebhookFinch)

{:ok, receiver} =
  Bandit.start_link(
    plug: WebhookReceiver,
    ip: {127, 0, 0, 1},
    port: 0,
    http_options: [compress: false]
  )

{:ok, {_, port}} = ThousandIsland.listener_info(receiver)

try do
  request =
    Req.new(
      url: "http://127.0.0.1:#{port}/webhooks",
      method: :post,
      json: %{"event" => "created"},
      finch: [name: WebhookFinch],
      retry: false
    )

  # verify: :none skips response verification; the receiver verifies this request.
  {:ok, request} =
    RequestSeal.Req.attach(request, sign: webhook_signing, signer: webhook_handle, verify: :none)

  {:ok, response} = Req.request(request)
  # {200, "valid: created"}
  IO.inspect({response.status, response.body})

  unsigned =
    Req.post!("http://127.0.0.1:#{port}/webhooks", json: %{"event" => "created"}, retry: false)

  # 401
  IO.inspect(unsigned.status)

  unless response.status == 200 and response.body == "valid: created" and unsigned.status == 401,
    do: raise("webhook verification failed")
after
  Supervisor.stop(receiver)
  Application.delete_env(:webhook_demo, :signature_policy)
  Supervisor.stop(webhook_pool)
  RequestSeal.Custody.Local.release(webhook_handle)
  Enum.each(Enum.reverse(webhook_apps), &Application.stop/1)
end

Expected output (Bandit may first log one [info] Running WebhookReceiver ... line; when run inside an existing VM, cleanup may log [notice] Application ... exited: :stopped for each application this script started):

{200, "valid: created"}
401

verify: :none leaves the response unverified; the receiver verifies the request. Req and Finch covers signed responses and retries. Supervise and reuse keys and pools in your application.

Sign outgoing requests with Req

Save as req.exs and run mix run req.exs with the dependency list above. The webhook example shows how a receiver verifies a signed request.

Configure :my_app, :outgoing_url in config/config.exs with the URL of a receiver you own, for example config :my_app, :outgoing_url, "https://your-receiver.example/". Start that receiver first; the expected status below assumes it returns 200. The example sends an actual HTTP request to your configured URL.

{_public, seed} = :crypto.generate_key(:eddsa, :ed25519)
{:ok, handle} = RequestSeal.Custody.Local.new("ed25519", {:ed25519, seed})
{:ok, apps} = Application.ensure_all_started(:req)
{:ok, pool} = Finch.start_link(name: OutgoingFinch)
try do
  request = Req.new(url: Application.fetch_env!(:my_app, :outgoing_url), finch: [name: OutgoingFinch], retry: false)
  spec = %{label: "sig", algorithm: "ed25519", components: ~s[("@method" "@scheme" "@authority" "@path")], expires_in: 60}
  # verify: :none skips verification of this unsigned response.
  {:ok, request} = RequestSeal.Req.attach(request, sign: spec, signer: handle, verify: :none)
  {:ok, response} = Req.request(request)
  IO.inspect(response.status)
after
  Supervisor.stop(pool)
  RequestSeal.Custody.Local.release(handle)
  Enum.each(Enum.reverse(apps), &Application.stop/1)
end

Expected output (cleanup inside an existing VM may also log application-stop notices):

200

In Phoenix

Phoenix uses the same Plug integration. The code below uses webhook_policy from the webhook example to configure the policy at startup. In your app, construct it with your trusted sender's public key. The request-time function form reads the current policy on each request. Add the shown plugs and route to your existing endpoint and router; Capture must precede the endpoint's Plug.Parsers.

Application.put_env(:my_app, :http_signature_policy, webhook_policy)

defmodule MyAppWeb.SignedWebhookPipeline do
  use Plug.Builder

  def signature_policy, do: Application.fetch_env!(:my_app, :http_signature_policy)

  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: &__MODULE__.signature_policy/0,
    label: "sig",
    on_reject: {:halt, 401}
  )
end

defmodule MyAppWeb.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

defmodule MyAppWeb.Router do
  use Phoenix.Router

  scope "/", MyAppWeb do
    post("/webhooks", WebhookController, :create)
  end
end

defmodule MyAppWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :my_app

  plug(MyAppWeb.SignedWebhookPipeline)

  plug(Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Jason
  )

  plug(MyAppWeb.Router)
end

The module definitions print no output. A valid POST /webhooks with {"event":"created"} returns status 200 and JSON {"event":"created","signature_label":"sig"}; an unsigned request returns 401 with an empty body.

Phoenix and Plug covers trusted proxies and response signing. Signing and verifying explains core results, errors, and exact wire control.

More examples

Web Bot Auth

Uses message, key, and handle from Hello world. Web Bot Auth identifies signed requests from agents and crawlers. Sign with protocol-00:

{:ok, thumbprint} = RequestSeal.PublicKey.thumbprint(key)
signer = fn "ed25519", bytes -> RequestSeal.Custody.sign(handle, bytes) end
now = System.system_time(:second)

{:ok, agent_request} =
  RequestSeal.WebBotAuth.sign(
    message,
    %{
      label: "agent",
      agent: %{location: "https://agent.example", type: :directory},
      key: key,
      algorithm: "ed25519",
      created: now,
      expires: now + 60,
      nonce: Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
    },
    signer
  )

IO.puts("signed agent request")

Expected output:

signed agent request

Verify against the public key's thumbprint, a hash identifying that key. This held-key policy trusts the key without attributing ownership of agent.example:

{:ok, agent_policy} =
  RequestSeal.WebBotAuth.Policy.new(%{
    algorithms: ["ed25519"],
    agents: fn _ -> :error end,
    cache: nil,
    unresolved:
      {:held_keys,
       fn
         %{keyid: ^thumbprint} -> {:ok, %{algorithm: "ed25519", key: key}}
         _ -> :error
       end},
    freshness: %{clock: fn -> System.system_time(:second) end, max_age: 60, skew: 5},
    content: :not_required,
    replay: :not_required
  })

{:ok, envelope} = RequestSeal.WebBotAuth.verify(agent_request, agent_policy)
verification = envelope.signatures["agent"]
IO.inspect(verification.signature.crypto)

Expected output:

:valid

To authenticate an agent URL, resolve it through a source you trust. Web Bot Auth shows discovery, nested signatures, and draft-specific errors.

Ash

Uses verification and thumbprint from Web Bot Auth above. That result identifies the held key. Map that principal to an actor and tenant, then read your AshApp.Document resource. This example assumes the resource has attribute-based multitenancy and a read policy for %{role: :reader}; the Ash guide includes the resource definition.

{:ok, scope} =
  RequestSeal.Ash.scope(verification, %{
    actor: fn
      %{kind: :key, thumbprint: ^thumbprint} -> {:ok, %{role: :reader}}
      _ -> :error
    end,
    tenant: {:value, "demo"},
    unattributed: :reject
  })

documents = Ash.read!(AshApp.Document, scope: scope, authorize?: true)
IO.inspect(scope.tenant)

Expected output:

"demo"

Your mapping chooses the actor and tenant; Ash policies decide access. Generic RFC 9421 verification does not attribute an identity. Choose unattributed: :anonymous explicitly for anonymous access, or reject it.

Replay protection

Uses signed and policy from Hello world. Accept that request only once. If its 60-second validity window has elapsed, rerun that example's signing step. The Hello world policy checks the signature and freshness; it does not require body integrity. This policy also claims its nonce, the random value identifying the request:

{:ok, replay_pid} = RequestSeal.Replay.ETS.start_link(max_entries: 10_000)

replay = %{
  identifier: :nonce,
  namespace: "demo-api",
  commitment: fn facts -> {:ok, facts.identifier} end,
  store: RequestSeal.Replay.ETS.store(replay_pid),
  timeout: 5_000
}

{:ok, replay_policy} = RequestSeal.Policy.new(%{Map.from_struct(policy) | replay: replay})
{:ok, accepted} = RequestSeal.verify(signed, replay_policy, label: "sig")
IO.inspect(accepted.signature.crypto)

Expected output:

:valid

A second verification rejects the same nonce:

{:error, replay_error} = RequestSeal.verify(signed, replay_policy, label: "sig")
IO.inspect(replay_error.reason)

Expected output:

:replayed

Here one trusted key uses one namespace. Define your own commitment for your trust and tenant boundaries. ETS is local and loses claims when its owner stops; supervise the store in your application. Replay protection covers retention, sweeping, and PostgreSQL storage.

What's included

Capability Module Standard
HTTP request/response signatures and quorum RequestSeal, RequestSeal.Quorum RFC 9421
Structured HTTP fields RequestSeal.StructuredFields RFC 9651, with explicit RFC 8941 schemas where required
Content and representation digests RequestSeal.Digest RFC 9530
Compact JWS/JWE and one JWS inside one JWE RequestSeal.JOSE RFC 7515, 7516, 7518
Public keys and SHA-256 JWK thumbprints RequestSeal.PublicKey RFC 7638, 8037
Web Bot Auth signing and verification RequestSeal.WebBotAuth Web Bot Auth protocol-00 draft
Bounded HTTPS key discovery: JWKS, key directories, CIMD RequestSeal.Discovery RFC 7517, Web Bot Auth protocol-00, CIMD-02

Integrations

Integration Optional dependency Entry module What it does
Plug (including Phoenix) :plug RequestSeal.Plug Capture request bytes, verify before controllers, sign final responses
Req :req, :finch RequestSeal.Req Sign each finalized attempt and verify responses before decoding or delivery
Finch :finch RequestSeal.Finch Sign requests and verify responses against the exact sent request
Ash :ash RequestSeal.Ash Map verified facts to actors, tenants, and scopes with authorization enabled
ash_onetime :ash_onetime RequestSeal.Replay.AshOnetime Recommended durable atomic replay with caller-owned Postgres
ash_hooks :ash_hooks RequestSeal.AshHooks.Http, RequestSeal.AshHooks Sign deliveries and verify ingress; ash_hooks owns transport and ledger
PostgreSQL replay :postgrex RequestSeal.Replay.Postgres Atomically claim nonces using your connection and table

Compatibility

Elixir 1.18 or newer on OTP 27 or newer. CI tests 1.18.4/27, 1.19.5/28, and 1.20.4/29. The optional ash_onetime and ash_hooks integrations require Elixir 1.20; floor and mid toolchains omit those dependencies and tests. The latest CI lane executes them. Development instructions work on macOS and Linux; Windows developers use WSL2.

Security model

A valid signature proves which key signed which covered bytes; a trusted association establishes who holds that key. Your application still decides what that signer may do. Read the threat model for the trust boundaries and SECURITY.md to report a vulnerability.

Documentation

Guides

Contributing

See CONTRIBUTING.md for contributor checks.

License

Apache-2.0. RFC code components use BSD-3-Clause under NOTICE; external vectors retain their attribution there.

Conformance corpus

The TypeScript counterpart is designed and in progress as a separate npm package named request-seal. Both implementations use the independently sourced conformance corpus format. Corpus data lives in corpus/ and is published as a checksummed release asset.