MessageSignatures
RFC 9421 HTTP Message Signatures for Elixir: create and verify signatures over HTTP message components with strict signature-base canonicalization.
Why RFC 9421
RFC 9421 is the IETF standard for
creating, encoding, and verifying signatures or message authentication codes
over HTTP message components. It defines the signature base, cryptographic
algorithms, and the Signature-Input and Signature fields.
Deployment profiles are emerging around the standard. Mastodon documents its
RFC 9421 support and migration
from the older Cavage draft, while Xsolla's
payment-provider implementation guide
uses RFC 9421 together with Content-Digest.
Installation
Add message_signatures to the dependencies in mix.exs:
def deps do
[
{:message_signatures, "~> 0.1"}
]
end
Quick Start
Build a request, sign it, append the returned Signature-Input and
Signature headers, then verify it through an application key resolver:
defmodule MyApp.KeyResolver do
@behaviour MessageSignatures.KeyResolver
@secret "replace-with-a-strong-shared-secret"
def secret, do: @secret
@impl true
def resolve_verify_key("demo-key", _context), do: {:ok, {:hmac_sha256, @secret}}
def resolve_verify_key(_key_id, _context), do: {:error, :unknown_key}
end
message =
MessageSignatures.Message.from_request(
method: :post,
url: "https://api.example.com/payments?attempt=1",
headers: [{"content-type", "application/json"}]
)
{:ok, signature_headers} =
MessageSignatures.sign(message,
key: {:hmac_sha256, MyApp.KeyResolver.secret()},
key_id: "demo-key",
params: [created: :now, alg: true]
)
signed_message = %{message | headers: message.headers ++ signature_headers}
MessageSignatures.verify(signed_message,
key_resolver: MyApp.KeyResolver,
policy: [
required_components: ["@method", "@target-uri"],
algorithms: [:hmac_sha256],
max_age: 300,
clock_skew: 30,
require_created: true
]
)
#=> {:ok, %MessageSignatures.VerifyResult{algorithm: :hmac_sha256, ...}}
The http_digest Stack
RFC 9421 does not directly sign message content. With http_digest (RFC
9530), generate a Content-Digest field for the body, then cover that field
in the signature. MessageSignatures verifies the signature over the
content-digest header; HTTPDigest verifies that digest against the body.
Both checks are needed. Add http_digest as an application dependency to
enable this composition.
For outgoing Req requests, MessageSignatures.Req.attach/2 defaults
digest: true. When http_digest is available, it attaches HTTPDigest.Req
before its signing step so the signature can cover the resulting
Content-Digest field:
Req.new(base_url: "https://api.example.com")
|> MessageSignatures.Req.attach(
key: {:ed25519, private_key},
key_id: "my-key-2026",
components: ["@method", "@authority", "@path", "content-digest"]
)
|> Req.post!(url: "/payments", body: payload)
For inbound Plug requests, retain the body while Plug.Parsers reads it,
verify the digest against that body, then verify the message signature over
the content-digest header:
plug Plug.Parsers,
parsers: [:json],
json_decoder: JSON,
body_reader: {HTTPDigest.Plug, :read_body, []}
plug HTTPDigest.Plug, required: true
plug MessageSignatures.Plug,
key_resolver: MyApp.KeyResolver,
policy: [required_components: ["@method", "@target-uri", "content-digest"]]
Verification Policy
verify/2 fails closed. Set an explicit policy for every protocol profile;
the defaults are a conservative baseline, not a replacement for one.
| Policy | Default | Cost of relaxing it |
|---|---|---|
required_components | Requests require ["@method", "@target-uri"]; responses require ["@status"] | A signature can stop binding the request method or target, or the response status, to its protected meaning. |
algorithms | :all: every implemented algorithm is allowed; the resolver selects the algorithm from the resolved key, and a supplied alg parameter is only cross-checked | The default can accept a valid but out-of-profile algorithm. Restrict the list to the algorithms allowed by your protocol. |
max_age | 300 seconds | A larger value or :infinity enlarges the replay window for a captured signature. |
clock_skew | 30 seconds | A larger value accepts more future-dated signatures and widens the effective validity window. |
require_created | true | Without created, freshness checks lose their normal issuance-time anchor and replay defenses become weaker. |
The registered algorithms are hmac-sha256, ed25519,
ecdsa-p256-sha256, ecdsa-p384-sha384, rsa-pss-sha512, and
rsa-v1_5-sha256. A key resolver owns key lookup and returns
{:ok, {algorithm, material}}; it is the authority for verification key
selection.
Interoperability Debugging
When another implementation disagrees, compare the exact signature base before comparing cryptography:
{:ok, base} =
MessageSignatures.signature_base(message, ["@method", "@target-uri"],
created: created,
keyid: "demo-key",
alg: "hmac-sha256"
)
IO.puts(base)
Diff those bytes against the peer's base, including component order, derived
component values, and the serialized @signature-params line.
ECDSA is a frequent wire-format trap: RFC 9421 uses fixed-width raw r || s
bytes, while many crypto APIs produce and consume ASN.1 DER signatures.
RSA-PSS is another: both peers must match the salt length and MGF1 digest; this
implementation uses SHA-512 with a 64-byte salt for rsa-pss-sha512.
Scope
This package deliberately does not implement:
Accept-Signature- Full multi-signature workflows
- Trailers
- JWKS or key distribution
- Nonce storage
- Cavage draft compatibility
MessageSignatures.KeyResolver leaves key fetching, caching, rotation, and
outages to the application. MessageSignatures.NonceChecker leaves replay
storage to the application as well. For Cavage deployments, use a migration
boundary informed by the Mastodon RFC 9421 migration notes
rather than mixing the two wire formats in one verifier.
License
MIT. See LICENSE.