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:
- Verify signed webhooks and service-to-service calls.
- Sign outgoing API requests.
- Identify and verify AI agents and crawlers with Web Bot Auth.
- Turn a verified caller into an Ash actor and tenant.
- Protect against replayed requests.
- Decide access. A verification result states facts about a signature; your application, or Ash policies, decide what the caller may do.
- Choose trust for you. Every policy names its algorithms, keys, covered components, freshness, body integrity, and replay rules. Keys come from your resolver or from discovery sources you configure; a key ID or URL in a request never selects one by itself.
- Run in the background. Loading the library starts no process, pool, store, or network fetch. You start and supervise key holders, replay stores, discovery caches, and HTTP pools.
- Validate JWT claims. The JOSE modules handle compact JWS and JWE envelopes only.
Add this entry to your mix.exs dependency list, then run mix deps.get:
{:request_seal, "~> 0.4.1"}Install RequestSeal 0.4.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.4.1"},
{:req, "~> 0.7.4"},
{:finch, ">= 0.23.0 and < 0.25.0"},
{:plug, "~> 1.20.3"},
{:bandit, "~> 1.12.5"},
{:jason, "~> 1.0"}
]- Key handle: an opaque reference to a key held by a custodian, such as a local holder process or an SSH agent. Private key bytes stay with the custodian.
- Message: the request or response, including its exact body and ordered headers.
- Signing spec: the label, algorithm, coverage, and lifetime of a signature.
- Covered components: the
("@method" ...)list is RFC 9421's syntax for which parts of the request are signed. - Policy: the verifier's explicit choices about keys, coverage, freshness, body integrity, and replay.
- Verification result: the facts established by those checks; your application decides access.
Sign a request and verify it. Run this in iex -S mix in a project that has RequestSeal installed:
{_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
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.
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)
endExpected 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.
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 with the URL of a receiver you own. mix new
does not create a config file, so add config/config.exs containing
import Config and 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)
endExpected output (cleanup inside an existing VM may also log application-stop notices):
200
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)
endThe 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.
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.
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.
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 durable storage with ash_onetime or PostgreSQL.
| Capability | Module | Standard |
|---|---|---|
| HTTP request/response signatures and quorum | RequestSeal, RequestSeal.Quorum |
RFC 9421 |
Signature negotiation with Accept-Signature |
RequestSeal.AcceptSignature |
RFC 9421 Section 5 |
| 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 |
HTTP signature algorithms (RequestSeal.Crypto.algorithms/0): ed25519, ecdsa-p256-sha256, ecdsa-p384-sha384, rsa-pss-sha512, rsa-v1_5-sha256, and hmac-sha256. HTTP signatures can also use the JWS algorithms RS256, PS256, PS384, PS512, HS256, ES256, ES384, and EdDSA by explicit {:jws, name} selection.
| 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 |
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.
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.
RequestSeal's tests run an independently sourced conformance corpus of published vectors and cases, kept in corpus/. GitHub releases attach it as corpus.tar.gz with corpus.sha256; the latest corpus release is v0.4.0. The TypeScript implementation, @request-seal/core on npm (baselabs/request-seal-typescript), runs the same corpus.
- Sign and verify without a framework, including verification results and quorum
- Req and Finch
- Phoenix and Plug
- Web Bot Auth
- Ash actors, tenants, and authorization
- Webhooks with ash_hooks
- Replay protection
- Key discovery
- JOSE: JWS and JWE
- Key custody
- Rotate signing keys
- Diagnose and handle errors
- Test your integration
- Livebooks: executable notebooks for the core flow, a published RFC vector, Plug, Web Bot Auth, and JOSE
API reference: HexDocs. Release notes: CHANGELOG.
- Develop RequestSeal and testing
- Architecture, threat model, ADRs, glossary, and standards
See CONTRIBUTING.md for contributor checks.
Apache-2.0. RFC code components use BSD-3-Clause under NOTICE; external vectors retain their attribution there.