Skip to main content

Decoding Phoenix Session Cookies

· 8 min read
Philipp Tessenow

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 • Own work Bitcrowd

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


How Phoenix decodes the cookieLink to section 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} -> # ...
|> decode(serializer, log)

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)
# ...

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}

# ...

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}
_ -> :error

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
try do
# ...

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)

These two methods can be simplified to just one line:


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 e 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)
# %{
# "_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


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 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)

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.

Philipp Tessenow

Philipp Tessenow

Tech-nerd & Intranet-gangster

We're hiring

Work with our great team, apply for one of the open positions at bitcrowd