Rails 7.1 generate_token_for functionality for Phoenix

Hi everyone,

I always jump around between languages and frameworks to see what is new and to keep my general knowledge up to date if I need to use a different language or framework.

Lately I’ve been spending some time with Rails 7.1 and got a small crush on the authentication work they’ve done.

Since I have a couple of Phoenix side projects that I’ve built my own auth for, the empty columns for confirmation_token and reset_password_token has been a bit of a nuisance and I don’t like the phx.gen.auth setup with a separate table to handle tokens either.

Anyway to the actual point. In Rails they now have generate_token_for which avoids the entire need for additional columns just to handle password reset, updating email or confirming an account.

I’ve written a small PoC in Elixir that essentially solves the same issue and would like to hear some feedback, thoughts or suggestions on this.

First the TokenFor module:

defmodule MyApp.TokenFor do
  @moduledoc """
  Token management for various purposes.
  """

  alias Phoenix.Token
  alias MyAppWeb.Endpoint

  @type token_definition :: %{
          expires_in: integer(),
          payload_func: function()
        }

  @spec generate_token_for(Ecto.Schema.t(), token_definition) :: String.t()
  def generate_token_for(model, %{expires_in: expires_in, payload_func: payload_func}) do
    payload = payload_func.(model)

    Token.sign(Endpoint, Endpoint.config(:secret_key_base), {model.id, payload},
      max_age: expires_in
    )
  end

  @spec find_by_token_for(String.t(), token_definition, (integer() -> Ecto.Schema.t() | nil)) :: {:ok, Ecto.Schema.t()} | {:error, atom()}
  def find_by_token_for(token, %{payload_func: payload_func}, fetch_model_func) do
    case Token.verify(Endpoint, Endpoint.config(:secret_key_base), token) do
      {:ok, {id, payload}} ->
        model = fetch_model_func.(id)

        cond do
          model && payload == payload_func.(model) -> {:ok, model}
          model -> {:error, :invalid}
          true -> {:error, :not_found}
        end

      {:error, :expired} ->
        {:error, :not_found}

      {:error, _} ->
        {:error, :invalid}
    end
  end
end

This gives us everything we need to create and find users for tokens generated.

On the User model we just add a function for the definition we need, for example:

  def password_reset_definition do
    %{
      expires_in: 15 * 60, # 15 minutes in seconds
      payload_func: fn model -> model.email end
    }
  end

We can then generate a token with:

token = TokenFor.generate_token_for(user, User.password_reset_definition())

and retrieve the user with:

TokenFor.find_by_token_for(token, User.password_reset_definition(), fn id -> Repo.get(User, id) end)

What do you think?

Keep in mind there is a security drawback from this implementation. You no longer have fine-grained control to revoke tokens on the server. This is particularly important for sessions: you want to be able to revoke individual sessions and avoid session replay attacks. Given we need to implement DB tokens for sessions (it is pretty much considered a security best practice), then reusing the same structure for passwords and confirmations is a small step with similar benefits.

6 Likes

My focus is primarily on specific use-cases like password resets, email updates, and account confirmations. I fully agree that this should not be used for session tokens.

Having a mostly empty sent_to column in the users_tokens table seems inefficient and somewhat inelegant to me.

Given these considerations, I find the stateless token approach to be a more efficient and cleaner solution for these particular scenarios.

I’m curious as to what you don’t like about the separate table solution as you didn’t really say. I like that it holds a record of a real-life event waiting to happen and once it happens, the row is deleted without a trace. It seems pretty unobtrusive. It also makes it trivial to build dashboards around these things.

Poorly worded by me.

I don’t have an issue with the user_sessions table per se, but the sent_to column feels misplaced, especially when it remains empty for the majority of entries.

1 Like

I can understand inelegant but I doubt it being inneficient.

It also plays an important security feature. Otherwise someone can do this:

  1. Create an account for i_own@example.com
  2. Receive the confirmation token
  3. Swap my email to i_dont_own@example.com
  4. Now confirm as i_dont_own@example.com

So tokens must be tied to an email and having the column there is a helpful reminder. If you don’t do it on your Phoenix.Token approach, then it is vulnerable.

In any case, I would go with your approach if we didn’t have the table. Otherwise having both will be more confusing. At the same time, I worry that providing those facilities and telling people to roll their own auth features will be full of pitfalls like above.

4 Likes

Ha, I get it. It seems a bit more OCD—which I very, very much sympathize with—than an actual problem, though. It’s really nice being able to revoke tokens by simply deleting a db record!

The email is in the payload so that wouldn’t work. And with inefficient I more meant in the overhead needed to manually deal with the short lived tokens (database cleanup etc) which would be handled automagically by the tokens themselves in my example.

Anyway I appreciate the feedback and somewhat agree that it could be confusing.

Not really OCD, but more exploring, questioning and improving an existing solution. I’ve never had to delete a token for account confirmation or a password reset but maybe I’ve been lucky? :slight_smile:

1 Like

Honestly, me neither, but in theory it’s good :joy: More so what José said about having a unified solution as it extends to the UI where the same code works for revoking any kind of token. In any event, I certainly didn’t mean to dismiss your work and I do appreciate your exploration and desire to improve! I was actually working on a UI for this stuff today which is why I felt the urge to comment.

It’s one of those things where it doesn’t much matter, until it really does—people who target your platform for abuse rarely do so gently when they discover it’s exploitable.

I’ve worked for a few companies where this has gone south overnight. On one, we had to freeze new signups and re-rewite a bunch of code because the situation was so bad. On the other, we simply had to blank out a few critical columns in a few critical rows.

Malicious ex-employees with a valid client-side session token rewriting content to send death threats, people farming your mailer to drive successful emails to boost the ranking of a domain as a sender to thwart spam filters, your password reset flow being exploited to send erection pill spam—none of it happens, until it does en masse, and that’s when you start to thank the stars for secure defaults!

I’d say it’s an elegant way to correctly model the problem domain. If you’re worried about inefficiency, that’s mostly a matter of indexing for runtime performance—consider a covering index—and for storage inefficiency, I think we’d all take a few extra bytes per user as an ounce of prevention, in exchange for the pound of cure that comes if you have to freeze operations to deal with an abuse vector.

6 Likes