wfgilman

wfgilman

How to: Plug which verifies header signature signed using request body

I’m writing up this quick “How to” because what I thought was going to be an easy implementation of a Plug to validate a webhook request turned out to be more complicated than I expected. Hopefully this can save others some time. This is for a JSON API.

Prior discussions here, and here. Recommended approach here by Jose from these discussions.

Goal: Validate a request signature which is a SHA256 HMAC of the request body signed by your secret key.

Challenge: The request body can only be read once from the conn, and it is done by Plug.Parsers before the request even reaches the router.

Solution: Normally, I add a webhook endpoint to my router, and add a controller which does something with the webhook, like store it in a queue for later processing. I can put the webhook route inside a pipeline, or add a Plug to my controller which verifies the header before processing the request.

However, I need the request body (as a string) to sign and compare to the signature in the header. It can only be accessed in this way using Plug.Conn.read_body/2. Unfortunately, that function is called by Plug.Parsers before the request hits the router.

Therefore, I need to create a Plug which is added to the endpoint of my app, before Plug.Parsers, here in endpoint.ex:

...
plug Plug.RequestId
plug Plug.Logger

# --> Add my Plug here <--
plug Plug.Parsers,
  parsers: [:urlencoded, :multipart, :json],
  pass: ["*/*"],
  json_decoder: Poison

plug Plug.MethodOverride
plug Plug.Head
...

Also, the Plug has to actually handle the request and then halt the conn. Once the request body is read, the subsequent plug, Plug.Parsers will fail. This means I can’t use a controller. It also means every request to the endpoint will go through my Plug; so I need a way of only taking action on request to the endpoint I care about.

I used a module plug for this. I copied the format from the recommended approach linked above (credit to https://github.com/hamiltop).

init/1 and call/2 are simple. init/1 just passed along the options with no modifications. I actually need to do modifications here, but I punt to a private function after I know whether the request is to the endpoint I care about. Otherwise every request will go through those modification - unnecessary overhead.

def init(opts), do: opts

call/2 checks the request path. It just returns the connection to continue one if it doesn’t match, otherwise it performs the verification and ultimately halts it.

def call(conn, opts) do
  mount = Keyword.get(opts, :mount)
  case conn.request_path do
    ^mount ->
      verify_signature(conn, opts)
    _ ->
      conn
  end
end

verify_signature/2 does the work of verifying the header signature. If correct, it will handle the request. If not, it will return a 401 error. handle_request/2 here is the equivalent of create/2 if I were using a Phoenix.Controller. (All my webhooks come in as POST requests.)

defp verify_signature(conn, opts) do
  opts = prepare_options(opts) # Normally my init/1 function.
  with [request_signature|_] <- get_req_header(conn, opts[:header]),
       secret when not is_nil(secret) <- opts[:secret],
       {:ok, body, _} <- Plug.Conn.read_body(conn),
       signature = Plug.Crypto.MessageVerifier.sign(body, secret, :sha256),
       true <- Plug.Crypto.secure_compare(signature, request_signature) do
    handle_webhook(conn, Poison.decode!(body))
  else
    nil ->
      Logger.error(fn -> "Webhook secret is not set" end)
      halt send_resp(conn, 401, "")
    false ->
      Logger.error(fn -> "Received webhook with invalid signature" end)
      halt send_resp(conn, 401, "")
    _ ->
      halt send_resp(conn, 401, "")
  end
end

Important: Note how each response includes halt/1. This ensures the conn won’t proceed to the next plug and foul things up (since the request body has already been read). Whatever you implement for handle_webhook/2, it needs to also included halt/1 with its response.

defp handle_webhook(conn, webhook) do
  event_params = get_params(webhook)
  with {:error, changeset} <- Event.store(event_params),
       true <- is_nil(changeset.errors[:resource_topic]) do
    Logger.warn(fn -> "Failed to store event: #{inspect changeset}" end)
  end
  halt send_resp(conn, 200, "")
end

Finally, my Plug is placed in endpoint.ex at the location noted above.

MyApp.Plug.Webhook, mount: "/v1/webhooks", header: "X-Request-Signature-Sha-256", secret: "s3cret"

Where Next?

Popular in Guides/Tuts Top

hauleth
Some time ago someone suggested me to write article about how I have configured my Vim to work with Elixir, now there it is: https://med...
New
9mm
So I’m really loving elixir. BY FAR the most excruciating piece of learning a functional language for me is having to “transform” all my ...
New
jtormey
Hello! Having written a lot of LiveView code, I’ve made some VS Code snippets to speed up writing callbacks for LiveViews and LiveCompon...
New
jshprentz
Geoffrey Lessel’s 2019 book, Phoenix in Action, was written for Phoenix 1.4. I found that the book’s code examples did not match the cur...
New
njwest
In the process of developing a Phx-based multiplayer experience, I found myself with so many browser tabs open with Elixir gaming resourc...
New
wfgilman
I'm writing up this quick "How to" because what I thought was going to be an easy implementation of a Plug to validate a webhook request ...
New
ben-pr-p
Hey all! I put together a starter-pack / instructions to set up Phoenix with the new parcel-bundler instead of brunch (or webpack). It’s...
New
water
I’ll post this here, It might help someone in the future. Feedback is greatly appreciated. I use it with direnv on NixOS, It should wor...
New
mudasobwa
The post covering how to generate nifty types to use in @spec in compile time with macros. https://rocket-science.ru/hacking/2020/07/15/...
New
kevinlang
Hey all, With Phoenix 1.6 just around the corner, I figured I’d make a tutorial on how to add Bulma to a new Phoenix 1.6 project. By lev...
New

Other popular topics Top

yurko
Here are few pieces of (common) Linux knowledge that we use for reasonably small one server apps. We use Ubuntu but this should work for ...
New
albydarned
Hello all! I am typing this post from my new MacBook Pro with the M1 chip. I’m loving it so far, and will probably use it as my daily dr...
New
sergio
I couldn’t find any guides that worked well with Phoenix 1.6.0 and esbuild. I hope this helps people test the waters and eases you into t...
New
axelson
This post is a wiki (feel free to hit the edit button near the bottom right of this post to add your own changes!) This post collects co...
239 45766 226
New
jononomo
I am trying to figure out how Mix knows whether the environment is test, dev, or prod -- where is this set? Thanks.
New
fireproofsocks
I’m working on defining a simple Ecto schema for a table (in PostGres), but I don’t see where I can define a column as NOT NULL. Conside...
New
myronmarston
The Elixir Typespec docs show the following syntax for keyword lists in typespecs: # ... | [key: type] # keyword lis...
New
mcarvalho
What is the difference between System.get_env and Application.get_env? For example, what are best practices to use one versus another.
New
danschultzer
None of the current solutions worked well for me, so I went ahead and built a user management system from scratch. This project took far...
548 27727 240
New
aadeshere1
I have a another noob question about loop. Since elixir is immutable, while loop is not directly possible. total = 10 while total != 0 ...
New

We're in Beta

About us Mission Statement