Skip to content
stoic man
January 13, 2025

Separate Session Tokens in Phoenix

Update the generated phx.gen.auth code to use a separate session token table

One of the pet-peeves that I have with the official phx.gen.auth generator is that it merges all the user tokens into a single table.

20250113135630_create_users_auth_tables.exs
create table(:users_tokens) do
  add :user_id, references(:users, on_delete: :delete_all), null: false
  add :token, :binary, null: false
  add :context, :string, null: false
  add :sent_to, :string
 
  timestamps(type: :utc_datetime, updated_at: false)
end

I'm not a big fan of this, because I consider it to be a leaky abstraction.

Why would my session tokens need sent_to ? This is for the email-change flow.

What if I want to add extra metadata, like the user-agent, or the IP address? I shouldn't do that in this table. My password reset token flow doesn't care about that.

I believe it's best to make this change early, before we get to unicorn status, and not have to deal with the migration when it's painful. In this post I'll focus on the session tokens, but the same principle applies to all others.

Step 1: Generate the migration

We keep the same structure, but without context & sent_to. Let's also add a unique index on the token. There are no surprises here.

20250113135630_create_session_tokens_table.exs
defmodule MyApp.Repo.Migrations.CreateSessionTokensTable do
  use Ecto.Migration
 
  def change do
    create table(:session_tokens) do
      add :user_id, references(:users, on_delete: :delete_all), null: false
      add :token, :binary, null: false
 
      timestamps(type: :utc_datetime, updated_at: false)
    end
 
    create index(:session_tokens, [:user_id])
    create unique_index(:session_tokens, [:token])
  end
end

Optional Step: Centralize the token generation

Well, here's a second pet-peeve. I don't enjoy having to call :crypto.strong_rand_bytes/1 everywhere in my code. It's a bit of a code smell. The hash_algorithm and rand_size are also things that are not going to change module to module.

So I like to centralize this in a helper module.

token_service.ex
defmodule MyApp.Helpers.TokenService do
  alias MyApp.Constants
 
  @hash_algorithm Constants.hash_algorithm()
  @rand_size Constants.rand_size()
 
  @spec generate_token() :: binary()
  def generate_token do
    :crypto.strong_rand_bytes(@rand_size)
  end
 
  @spec hash(binary()) :: binary()
  def hash(token) do
    :crypto.hash(@hash_algorithm, token)
  end
 
  @spec generate() :: {binary(), binary()}
  def generate do
    raw_token = generate_token()
    {raw_token, hash(raw_token)}
  end
 
  @spec encode(binary()) :: binary()
  def encode(token) do
    Base.url_encode64(token, padding: false)
  end
 
  @spec decode(binary()) :: {:ok, binary()} | :error
  def decode(encoded_token) do
    Base.url_decode64(encoded_token, padding: false)
  end
end

Step 2: Create the schema

OK, back to the juicy stuff. Let's create the SessionToken schema. There's only a single changeset method that generates the token, and prepares the changeset.

session_token.ex
defmodule MyApp.Accounts.SessionToken do
  use Ecto.Schema
 
  import Ecto.Changeset
 
  alias MyApp.Accounts.User
  alias MyApp.Helpers.TokenService
 
  schema "session_tokens" do
    field :token, :binary
 
    belongs_to :user, User
 
    timestamps(type: :utc_datetime, updated_at: false)
  end
 
  def changeset(user) do
    token = TokenService.generate_token()
 
    attrs =
      %{
        token: token,
        user_id: user.id
      }
 
    changeset =
      %__MODULE__{}
      |> cast(attrs, [:token, :user_id])
      |> validate_required([:token, :user_id])
      |> foreign_key_constraint(:user_id)
 
    {token, changeset}
  end
end

Step 3: Update the Accounts context

Finally, let's make small adjustments to the Accounts context. I'm also going to add a few new ones, revoke_other_sessions/2 and list_sessions/2.

accounts.ex
## Session
 
@doc """
Generates a session token.
"""
def generate_user_session_token(user) do
  {token, session_token_changeset} = SessionToken.changeset(user)
  Repo.insert!(session_token_changeset)
  token
end
 
@doc """
Gets the user with the given signed token.
"""
def get_user_by_session_token(token) do
  days = Constants.session_validity_in_days()
 
  SessionToken
  |> where([st], st.token == ^token)
  |> join(:inner, [st], u in assoc(st, :user))
  |> where([st], st.inserted_at > ago(^days, "day"))
  |> select([st, u], u)
  |> Repo.one()
end
 
@doc """
Deletes the signed token with the given context.
"""
def delete_user_session_token(token) do
  Repo.delete_all(from st in SessionToken, where: st.token == ^token)
  :ok
end
 
@doc """
Revokes all session tokens for a user except the current one.
 
## Parameters
  - user: User struct
  - current_token: Token to keep active
 
## Returns
  - {number_of_revoked_sessions, nil}
"""
def revoke_other_sessions(user, current_token) do
  Repo.delete_all(
    from st in SessionToken,
      where: st.user_id == ^user.id and st.token != ^current_token
  )
end
 
@doc """
Lists all active sessions for a user.
 
## Parameters
  - user: User struct
  - current_token: Current active session token
 
## Returns
  - List of session details
"""
def list_sessions(user, current_token) do
  SessionToken
  |> where(user_id: ^user.id)
  |> select([st], %{
    id: st.id,
    is_current: st.token == ^current_token
  })
  |> Repo.all()
end

Let's also update the reset_user_password/2 method to delete all the session tokens for the user.

accounts.ex
@doc """
Resets the user password.
 
## Examples
 
    iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
    {:ok, %User{}}
 
    iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
    {:error, %Ecto.Changeset{}}
 
"""
def reset_user_password(user, attrs) do
  Ecto.Multi.new()
  |> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
  |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all))
  |> Ecto.Multi.delete_all(
    :session_tokens,
    from(t in SessionToken, where: t.user_id == ^user.id)
  )
  |> Repo.transaction()
  |> case do
    {:ok, %{user: user}} -> {:ok, user}
    {:error, :user, changeset, _} -> {:error, changeset}
  end
end

I find Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all) to be sub-optimal as well, but I'll leave it as is for now.

Step 4: Update the Accounts context tests

Last but not least, let's update the tests. There's not much to change here (intentionally), we just have to ensure we're referencing the correct schema.

accounts_test.exs
describe "generate_user_session_token/1" do
  setup do
    %{user: user_fixture()}
  end
 
  test "generates a token", %{user: user} do
    token = Accounts.generate_user_session_token(user)
    assert user_token = Repo.get_by(SessionToken, token: token)
 
    # Creating the same token for another user should fail
    assert_raise Ecto.ConstraintError, fn ->
      Repo.insert!(%SessionToken{
        token: user_token.token,
        user_id: user_fixture().id
      })
    end
  end
end
 
describe "get_user_by_session_token/1" do
  setup do
    user = user_fixture()
    token = Accounts.generate_user_session_token(user)
    %{user: user, token: token}
  end
 
  # other tests omitted
  test "does not return user for expired token", %{token: token} do
    {1, nil} = Repo.update_all(SessionToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
    refute Accounts.get_user_by_session_token(token)
  end
end
 
describe "reset_user_password/2" do
  setup do
    %{user: user_fixture()}
  end
 
  # other tests omitted
  test "deletes all tokens for the given user", %{user: user} do
    _ = Accounts.generate_user_session_token(user)
    {:ok, _} = Accounts.reset_user_password(user, %{password: "new valid password"})
 
    refute Repo.get_by(UserToken, user_id: user.id)
    refute Repo.get_by(SessionToken, user_id: user.id)
  end
end

Step 5: Remove old session tokens

One last thing, be sure to remove the leftover session tokens in the users_tokens table, any way you see fit. I'm not going to provide an example here, as it's a one-time operation. You can do it manually, or write a migration for it.

Optional Step: Add some metadata

Let's make this change useful. We will expand the session token table, to include some metadata about the device that the user is using. The point here is to allow them inspect their active sessions, and see if there's anything suspicious.

We start by generating a migration to add the fields.

20250113135631_add_device_metadata_to_session_tokens.exs
defmodule MyApp.Repo.Migrations.AddDeviceMetadataToSessionTokens do
  use Ecto.Migration
 
  def change do
    alter table(:session_tokens) do
      add :device, :string, default: "Unknown"
      add :os, :string, default: "Unknown"
      add :browser, :string, default: "Unknown"
      add :browser_version, :string, default: ""
    end
  end
end

Next, we have to update the SessionToken schema to include the device metadata.

session_token.ex
schema "session_tokens" do
  field :token, :binary
  field :device, :string
  field :os, :string
  field :browser, :string
  field :browser_version, :string
 
  belongs_to :user, User
 
  timestamps(type: :utc_datetime, updated_at: false)
end

Then, update the changeset/1 method (now changeset/2) to accept these fields.

session_token.ex
  def changeset(user, device_info) do
    token = TokenService.generate_token()
 
    attrs =
      %{
        token: token,
        user_id: user.id
      }
      |> Map.merge(device_info)
 
    changeset =
      %__MODULE__{}
      |> cast(attrs, [:token, :user_id, :device, :os, :browser, :browser_version])
      |> validate_required([:token, :user_id, :device, :os, :browser, :browser_version])
      |> foreign_key_constraint(:user_id)
 
    {token, changeset}
  end

Finally, we update generate_user_session_token/1 method (now generate_user_session_token/2), and the list_sessions/2.

accounts.ex
def generate_user_session_token(user, device_info) do
  {token, session_token_changeset} = SessionToken.changeset(user, device_info)
  Repo.insert!(session_token_changeset)
  token
end
 
def list_sessions(user, current_token) do
  SessionToken
  |> where(user_id: ^user.id)
  |> select([st], %{
    id: st.id,
    is_current: st.token == ^current_token,
    device: st.device,
    os: st.os,
    browser: st.browser,
    browser_version: st.browser_version
  })
  |> Repo.all()

After that, we just have to parse our headers in our session controller, and extract the device info. Even if you're using LiveView for everything, you can't get away without having a session controller. So the same step applies.

In order to collect the device info, we can use various User-Agent parsers (like UAParser). That said, it's not a foolproof method. For example Brave sometimes gets detected as Chrome. You need client-hints to get the real info there.

MacOS has also stopped updating the User-Agent string, so it's not reliable. I believe it's stuck on 10_15_7, and it's not going to change.

So, I won't go into the details of how to parse the User-Agent string, it depends on how serious you want to get with this.

But essentially, you need something along these lines:

session_controller.ex
defmodule MyApp.UserSessionController do
  # other methods and imports omitted
  def create(conn, %{"user" => user_params}) do
    %{"email" => email, "password" => password} = user_params
 
    if user = Accounts.get_user_by_email_and_password(email, password) do
      device_info = extract_user_agent_info(conn)
 
      conn
      # Which passes it to `Accounts.generate_user_session_token/2`
      |> UserAuth.log_in_user(user, user_params, device_info)
    else
      # In order to prevent user enumeration attacks, don't disclose whether the email is registered.
      conn
      |> put_flash(:error, "You have entered an invalid email or password.")
      |> redirect(to: ~p"/users/log_in")
    end
  end
 
  def extract_user_agent_info(conn) do
    conn
    |> Plug.Conn.get_req_header("user-agent")
    |> List.first()
    |> parse_user_agent()
  rescue
    _ -> default_user_agent_info()
  end
 
  defp parse_user_agent(nil), do: default_user_agent_info()
  defp parse_user_agent(user_agent) do
    # parse the user agent
  end
end

Conclusion

That's it, we now have a separate table for session tokens, that can be easily extended with relevant metadata.

Now, let's recap why we did it.

The generator, creates what's effectively a "God Table", it kind of knows too much, and handles different concerns.

When you get a session token, you notice a sent_to. If you are not aware of the generator structure, and you see this, you have every right to be concerned. Session management, has nothing to do with email delivery. This is a classic example of a leaky abstraction, where implementation details of one concern are leaking into another.

So we separate them.

I hope you found this post useful. If I have messed something up, feel free to reach out to me on Twitter or BlueSky.