526 words, 3 min read

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.