Decoding Phoenix Session Cookies

/ Tessi

When debugging (or during security audits) it may be handy to know which data exactly is encoded in a session cookie. This is especially important because authentication frameworks like guardian store authentication secrets in sessions and we need to know they are stored securely. For the Phoenix web framework session cookies are encoded in a special format. In this post we follow Phoenix’ cookie storage implementation to find out how sessions are encoded.

We take apart session cookies in the following steps:

  1. Obtaining the raw cookie from the browsers dev tools
  2. Find out how Phoenix decodes cookies
  3. Find out how guardian stores authentication tokens in Phoenix session cookies
  4. Reproduce cookie decoding in a few lines of Elixir

We assume you set up Phoenix and Guardian with the Cookie Session Storage like this in your endpoint.exs:

plug Plug.Session,
    store: :cookie,
    key: "_myApp_web_key",
    signing_salt: "auFTRIdU"

After logging in to your application, you’ll find a session cookie (it has the configured key _myApp_web_key) in your browser’s dev-tools.

The storage panel opened to show cookies in Firefox dev tools
The storage panel opened to show cookies in Firefox dev tools • Own work Bitcrowd

This is the cookie we take as an example in its full glory (line breaks were added for readability):

SFMyNTY.g3QAAAACbQAAAAtfY3NyZl90b2tlbm0AAAAYdk85d0NndXNCUGRmRHZaRUdneldOR2txbQA
AABZndWFyZGlhbl9kZWZhdWx0X3Rva2VubQAAAZtleUpoYkdjaU9pSklVelV4TWlJc0luUjVjQ0k2SW
twWFZDSjkuZXlKaGRXUWlPaUppYVhSamNtOTNaQ0lzSW1WNGNDSTZNVFUzTURBeE1UUTFNU3dpYVdGM
Elqb3hOVGN3TURFd05UVXhMQ0pwYzNNaU9pSmlhWFJqY205M1pDSXNJbXAwYVNJNklqQXpPV0V6TXpB
M0xXTXpOVFV0TkdOa05TMDVNR1ZrTFdVM056RXpaV001TkRReU1pSXNJbTVpWmlJNk1UVTNNREF4TUR
VMU1Dd2ljM1ZpSWpvaVpUSTNNemN5TW1RdFpESmtZeTAwWXpnM0xXRmhNVGN0TmpZMU1UbGhNemxpTX
pobElpd2lkSGx3SWpvaVlXUnRhVzVmZEhkdlgyWmhZM1J2Y2w5aGRYUm9aVzUwYVdOaGRHVmtJbjAuW
ERSdGhNZHNfWDJZRk1JUWExaE40TFBTSXpSbE1DRnI4M2ZzdGctQ1FyeVdzTE9jeFZ6WVVIUVA2ekpP
aHR3MlllMEkySUF1M1FtRm9zdHE0c1ZURUE.W9o3M1raShhF8GZVzgRYRcbc5IAv6cRJEFFoDlrY-Pc

With the cookie at hand, let’s see how Phoenix decodes it on the server. The decoding happens in Plug.Session.COOKIE.get/3.

defmodule Plug.Session.COOKIE do
  alias Plug.Crypto.MessageVerifier

  # ...
  def get(conn, cookie, opts) do
    # ...

    case opts do
      %{encryption_salt: nil} ->
        MessageVerifier.verify(cookie, derive(conn, get_mfa(signing_salt), key_opts))

      %{encryption_salt: key} -> # ...
    end
    |> decode(serializer, log)
  end
end

We see that get/3 decodes the cookie differently based on whether the cookie is encrypted (an encryption_salt is present) or not. The default are signed-only cookies, which means cookies are signed and, thus, safe against external modifications but are open to be read by anyone obtaining the cookie. Let’s assume we don’t encrypt our cookies (if we did it would be hard to decode them without knowing the secrets anyways).

With that simplification and replacing some options with their defaults the code boils down to:

MessageVerifier.verify(cookie, "some_secret")
  |> decode(:external_term_format, some_error_logger)

Let’s first find out what MessageVerifier does before diving into the decode function.

Follow the trace into `MessageVerifier`

MessageVerifier is defined in Plug.Crypto. Let’s look at the source code around the verify/2 function:

defmodule Plug.Crypto.MessageVerifier do

  # ...
  @doc """
  Decodes and verifies the encoded binary was not tampered with.
  """
  def verify(signed, secret) do
    hmac_sha2_verify(signed, secret)
    # ...
  end

  defp hmac_sha2_verify(signed, key) do
    case decode_token(signed) do
      {protected, payload, plain_text, signature} ->
        # ...

        if Plug.Crypto.secure_compare(challenge, signature) do
          {:ok, payload}
        else
          :error
        end

      # ...
    end
  end

  defp decode_token(token) do
    with [protected, payload, signature] <- String.split(token, ".", parts: 3),
         {:ok, payload} <- Base.url_decode64(payload, padding: false),
         # ...
      {protected, payload, plain_text, signature}
    else
      _ -> :error
    end
  end
end

As the documentation string says, it verifies that the binary encoded cookie was not changed, meaning its signature matches the payload. In addition, it returns {:ok, payload} in the success case. When ignoring all the crypto and skipping the verification steps, the code can be simplified to:

[_, payload, _] = String.split(cookie, ".", parts: 3)
{:ok, decoded_token} = Base.url_decode64(payload, padding: false)
# => {:ok,
#     <<131, 116, 0, 0, 0, 2, 109, 0, 0, 0, 11, 95, 99, 115, 114, 102, 95, 116, 111,
#       107, 101, 110, 109, 0, 0, 0, 24, 118, 79, 57, 119, 67, 103, 117, 115, 66, 80,
#       100, 102, 68, 118, 90, 69, 71, 103, 122, 87, 78, ...>>}

Seems our cookie is a string of three parts, each divided by a dots ("."). The middle part is the payload (our actual cookie content). The other parts are present to make sure people didn’t temper with the cookies.

Now we know what MessageVerifier does and have a simple implementation to reproduce its work (without doing the actual verification). However, we can’t read the decoded_token yet. Let’s see how it is further processed after the verification step.

Remember we found out the high-level cookie decoding boils down to just the following?

MessageVerifier.verify(cookie, "some_secret")
  |> decode(:external_term_format, some_error_logger)

Since we solved the first line, we need to look at the implementation of decode/2:

defp decode({:ok, binary}, :external_term_format, log) do
  {:term,
   try do
     Plug.Crypto.safe_binary_to_term(binary)
   rescue
     # ...
   end}
end

It calls Plug.Crypto.safe_binary_to_term(binary), so let’s look at that:

defmodule Plug.Crypto do
  # ...

  @doc """
  A restricted version of `:erlang.binary_to_term/2` that
  forbids possibly unsafe terms.
  """
  def safe_binary_to_term(binary, opts \\ []) when is_binary(binary) do
    term = :erlang.binary_to_term(binary, opts)
    safe_terms(term)
    term
  end
end

These two methods can be simplified to just one line:

:erlang.binary_to_term(decoded_token)

But what does this line do? It goes one level down, from Elixir code to Erlang, and calls the Erlang function binary_to_term/1. The function is part of an encoding scheme called the Erlang external term format. It provides a way to serialize Erlang terms (which includes Elixir terms) into strings. The binary_to_term/1 function implements the part which converts a string back to an Erlang term.

Let’s summarize what we have so far and see what the “term” is that comes out of our string:

[_, payload, _] = String.split(cookie, ".", parts: 3)
{:ok, encoded_term } = Base.url_decode64(payload, padding: false)
:erlang.binary_to_term(encoded_term)
# %{
#   "_csrf_token" => "vO9wCgusBPdfDvZEGgzWNGkq",
#   "guardian_default_token" => "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJiaXRjcm93ZCIsImV4cCI6MTU3MDAxMTQ1MSwiaWF0IjoxNTcwMDEwNTUxLCJpc3MiOiJiaXRjcm93ZCIsImp0aSI6IjAzOWEzMzA3LWMzNTUtNGNkNS05MGVkLWU3NzEzZWM5NDQyMiIsIm5iZiI6MTU3MDAxMDU1MCwic3ViIjoiZTI3MzcyMmQtZDJkYy00Yzg3LWFhMTctNjY1MTlhMzliMzhlIiwidHlwIjoiYWRtaW5fdHdvX2ZhY3Rvcl9hdXRoZW50aWNhdGVkIn0.XDRthMds_X2YFMIQa1hN4LPSIzRlMCFr83fstg-CQryWsLOcxVzYUHQP6zJOhtw2Ye0I2IAu3QmFostq4sVTEA"
# }

The string is actually a map. This map contains the content of our session, like flash messages, csrf tokens, or the guardian_default_token which is the token users authenticate with.

Decoding the Guardian Token

The guardian token we found in our session is still a weird string that wants to be decoded. Let’s have another look at the token

eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJiaXRjcm93ZCIsImV4cCI6MTU3MDAxMT
Q1MSwiaWF0IjoxNTcwMDEwNTUxLCJpc3MiOiJiaXRjcm93ZCIsImp0aSI6IjAzOWEzMzA3LWMzNTUtN
GNkNS05MGVkLWU3NzEzZWM5NDQyMiIsIm5iZiI6MTU3MDAxMDU1MCwic3ViIjoiZTI3MzcyMmQtZDJk
Yy00Yzg3LWFhMTctNjY1MTlhMzliMzhlIiwidHlwIjoiYWRtaW5fdHdvX2ZhY3Rvcl9hdXRoZW50aWN
hdGVkIn0.XDRthMds_X2YFMIQa1hN4LPSIzRlMCFr83fstg-CQryWsLOcxVzYUHQP6zJOhtw2Ye0I2I
Au3QmFostq4sVTEA

Again, it looks like a string consisting of three parts separated by a dot ("."). This time we cannot simply decode it similar to how we decoded the session cookie. It would be weird to have a session within a session anyways, right?

Fortunately, Guardian’s documentation says that Guardian stores its session info in JWTs as by default. JWT is a special type of token. It consists (as we guessed) of three parts: a header, the payload, and a signature. We can decode it (e.g. via https://jwt.io) into the following:

JWT Header:

{
  "alg": "HS512",
  "typ": "JWT"
}

We see that the token was signed with the HS512 algorithm, which is better known as HMACSHA512.

JWT Payload:

{
  "aud": "bitcrowd",
  "exp": 1570011451,
  "iat": 1570010551,
  "iss": "bitcrowd",
  "jti": "039a3307-c355-4cd5-90ed-e7713ec94422",
  "nbf": 1570010550,
  "sub": "e273722d-d2dc-4c87-aa17-66519a39b38e",
  "typ": "admin_two_factor_authenticated"
}

The payload is highly dependent on the actual application, but some keys are common between all JWTs:

  • aud (documentation): The “audience” claim. It identifies for which audience the token was issued. In our example application we just hardcoded a value.
  • iss (documentation): The “issuer” claim. It identifies application created the token. In our example application we just hardcoded a value.
  • exp (documentation): The “expiration time” claim (as a UNIX timestamp). It encodes the time after which the token stops being accepted.
  • iat (documentation): The “issued at” claim. It encodes the time when the token was created.
  • jti (documentation): The “JWT ID” claim. A random ID assigned to the token. This could be used to blacklist certain tokens which would be valid otherwise (e.g. because they did not expire yet).
  • nbf (documentation): The “valid not before” claim. It encodes a time before which the token should not be considered valid.
  • sub (documentation): The “subject” claim. This is the main claim. It encodes in an application specific way, which object we created the token for. In our case it is the UUID of a user in our database.
  • typ: This is an application-specific key we use to identify which kind of token we issued. In this case and admin-backend token that was two-factor authenticated.

By following the decoding process of session cookies, we found that a simple elixir script enables us to read session cookies:

[_, payload, _] = String.split(cookie, ".", parts: 3)
{:ok, encoded_term } = Base.url_decode64(payload, padding: false)
:erlang.binary_to_term(encoded_term)

Furthermore, we found out that guardian saves a token (specifically a JWT) within the session which authenticates a user.

This gives us a simple tool to double-check the contents of Phoenix sessions and Guardian tokens and enables us to reason about their internals.

© 2020 bitcrowd GmbH.