When exposing a webhook endpoint, signature validation is essential. It ensures that incoming requests actually originate from the expected provider and that the payload has not been tampered with in transit.
Phoenix provides all the building blocks needed to implement this cleanly and generically, without coupling your code to a specific webhook provider.
This post shows a reusable pattern you can adapt to any HMAC-signed webhook.
The general webhook signature pattern
Most webhook providers follow a similar approach:
- They send a signature in a request header
- The signature is an HMAC of the raw request body
- A shared secret is used as the HMAC key
- The receiver must recompute the signature and compare it securely
While header names and algorithms may differ, the structure remains the same.
Capturing the raw request body
Signature verification requires access to the raw request body, before JSON decoding occurs. Phoenix parses the body eagerly, so you need to explicitly capture it.
Configure a custom body reader in your endpoint.
# endpoint.ex
plug Plug.Parsers,
parsers: [:json],
pass: ["application/json"],
json_decoder: Jason,
body_reader: {MyAppWeb.BodyReader, :cache_raw_body, []}
defmodule MyAppWeb.BodyReader do
def cache_raw_body(conn, opts) do
{:ok, body, conn} = Plug.Conn.read_body(conn, opts)
conn = Plug.Conn.assign(conn, :raw_body, body)
{:ok, body, conn}
end
end
The raw payload is now available as conn.assigns[:raw_body] for later validation.
A generic signature validation plug
Instead of hardcoding provider-specific details, you can write a reusable plug that accepts configuration options such as:
- Header name
- Hash algorithm
- Shared secret
- Optional encoding or prefix handling
Below is a minimal but flexible implementation for HMAC-based signatures.
defmodule MyAppWeb.Plugs.WebhookSignature do
import Plug.Conn
def init(opts) do
%{
header: Keyword.fetch!(opts, :header),
secret: Keyword.fetch!(opts, :secret),
algorithm: Keyword.get(opts, :algorithm, :sha256)
}
end
def call(conn, %{header: header} = opts) do
with [signature] <- get_req_header(conn, header),
raw_body when is_binary(raw_body) <- conn.assigns[:raw_body],
true <- valid_signature?(raw_body, signature, opts) do
conn
else
_ ->
conn
|> send_resp(:unauthorized, "Invalid signature")
|> halt()
end
end
defp valid_signature?(payload, signature, %{secret: secret, algorithm: algorithm}) do
expected =
:crypto.mac(:hmac, algorithm, secret, payload)
|> Base.encode16(case: :lower)
Plug.Crypto.secure_compare(expected, signature)
end
end
This plug makes no assumptions about the webhook provider beyond the use of an HMAC.
Applying the plug per webhook endpoint
Different webhook providers can now be configured independently at the router level.
# router.ex
pipeline :webhook_provider_a do
plug MyAppWeb.Plugs.WebhookSignature,
header: "x-webhook-signature",
secret: Application.fetch_env!(:my_app, :provider_a)[:secret]
end
pipeline :webhook_provider_b do
plug MyAppWeb.Plugs.WebhookSignature,
header: "x-signature",
secret: Application.fetch_env!(:my_app, :provider_b)[:secret],
algorithm: :sha512
end
scope "/webhooks", MyAppWeb do
pipe_through [:api, :webhook_provider_a]
post "/provider-a", ProviderAController, :create
end
This keeps validation close to routing and avoids leaking security concerns into controllers.
Keeping controllers focused
With signature validation handled by a plug, controllers can assume authenticity and focus purely on business logic.
def create(conn, params) do
json(conn, %{status: "ok"})
end
Common pitfalls
- Validating against parsed JSON instead of the raw request body
- Comparing signatures without a constant-time function
- Hardcoding secrets instead of injecting them via configuration
- Applying the plug after the request body has already been consumed
Conclusion
Webhook signature validation is a cross-cutting concern that fits naturally into Phoenix plugs. By capturing the raw request body and using a configurable, generic validation plug, you can support multiple webhook providers with minimal duplication while keeping your controllers clean and secure.
If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, you can email my personal email. To get new posts, subscribe use the RSS feed.