SendGrid webhook authentication with phoenix

For a project I’m currently building I need to verify that the email has been delivered to the user, or bounced. Luckily you can do this via SendGrids webhooks, and it’s even pretty simple

What’s not quite as simple is when you want to verify the webhook request. At least, I’d never even heard of the cryptographic signing method they use. Elliptic Curve Digital Signature Algorithm

But worry not! Erlang’s standard library and Plug has got us covered. Below is the all the coded needed to receive and verify the webhook request.

man vs potato as imagined by the AI, badly.

The Plug

This is where the magic happens.

defmodule MyAppWeb.SendGridWebhookAuth do
  import Plug.Conn

  def init(options), do: options

  def call(conn, _opts) do
    case verify_signature(conn) do
      :ok ->
        conn

      :error ->
        conn
        |> send_resp(403, "Invalid signature")
        |> halt()
    end
  end

  defp verify_signature(conn) do
    with {:ok, body, conn} = read_body(conn, opts),
         {:ok, signature} <- get_signature(conn),
         {:ok, timestamp} <- get_timestamp(conn),
         {:ok, public_key} <- get_public_key() do
      verify(body, signature, timestamp, public_key)
    else
      _ ->
        :error
    end
  end

  defp get_signature(conn) do
    case get_req_header(conn, "x-twilio-email-event-webhook-signature") do
      [signature] -> {:ok, signature}
      _ -> :error
    end
  end

  defp get_timestamp(conn) do
    case get_req_header(conn, "x-twilio-email-event-webhook-timestamp") do
      [timestamp] -> {:ok, timestamp}
      _ -> :error
    end
  end

  defp verify(body, signature, timestamp, public_key) do
    payload = timestamp <> body
    decoded_signature = Base.decode64!(signature)

    case :public_key.verify(payload, :sha256, decoded_signature, public_key) do
      true -> :ok
      false -> :error
    end
  end

  defp get_public_key() do
    token = Application.get_env(:aid, MyApp.Mailer)[:webhook_verification_key]
    public_key = "-----BEGIN PUBLIC KEY-----\n#{token}\n-----END PUBLIC KEY-----\n"

    case :public_key.pem_decode(public_key) do
      [{:SubjectPublicKeyInfo, "", :not_encrypted}] ->
        :error

      [{:SubjectPublicKeyInfo, der_key, :not_encrypted}] ->
        {:ok, :public_key.pem_entry_decode({:SubjectPublicKeyInfo, der_key, :not_encrypted})}

      _ ->
        :error
    end
  end
end

The Controller

The controller is very simple, I just receive the POST request, and take the events I care about from the message list.

defmodule MyAppWeb.Webhooks.SendGridController do
  use MyAppWeb, :controller
  @tracked_events ["delivered", "bounce", "deferred", "dropped"]

  def new(conn, %{"_json" => msg_list}) do
    msg_list
    |> Enum.map(&process_email_event/1)

    send_resp(conn, 200, "Ok")
  end

  def new(conn, params) do
    send_resp(conn, 422, "Invalid payload")
  end

  defp process_email_event(%{"event" => event} = params) when event in @tracked_events do
    # Do something with the params
  end

  defp process_email_event(_), do: :ignore
end

The Router

First we set up the pipeline to use the SendGridWebhookAuth plug.

  pipeline :sendgrid_webhook do
    plug :accepts, ["json"]
    plug MyAppWeb.SendGridWebhookAuth
  end

Then we add the route that we configured SendGrid to use.

  scope "/webhook/sendgrid", MyAppWeb do
    pipe_through :sendgrid_webhook
    post "/", Webhooks.SendGridController, :new
  end

And that’s it!