Don't want to miss out on Elixir news? Subscribe to ElixirPulse!

How To Add Magic Link Login to a Phoenix LiveView App

“Magic link” authentication has become an increasingly common way to sign in to web applications. Magic link authentication is where one receives a link in their email to sign in to a web application rather than enter their email and password (Slack is a popular example of this sign in flow).

This is how to simply, safely, and securely extend phx.gen.auth to add magic link authentication for a Phoenix LiveView application in 5 easy steps

This write-up uses the LiveView sign in/register pages. However, the code will work with the “dead” view version as well since the sign in logic takes place in regular controllers.

Don’t want to wait? See the full Repo here (user_session_controller.ex is where the magic happens)

The Steps

  1. Extend the sign in form
  2. Generate a secure sign in token
  3. Create the magic link email template
  4. Send the email
  5. Log in the user from the link

This guide was written with the following Elixir, Erlang, Phoenix, & LiveView versions:

elixir 1.15.0-otp-26
erlang 26.0.1
phoenix_live_view 0.19.0
phoenix 1.7.6

The steps apply to previous versions of LiveView but will require minor tweaking.

Let’s get started:

0. Run phx.gen.auth

If not already done, run phx.gen.auth to generate the scaffolding needed to implement magic link sign in:

mix phx.gen.auth Accounts User users

When prompted, enter Y to create a LiveView based authentication system (it’s the default).

Now run mix deps.get && mix ecto.migrate to re-fetch dependencies and migrate the database.

Now the pieces are in place, all that’s left to do is tie them together:

1. Extend the sign in form by adding a “Send me a link” button

Add another simple form underneath the login form at user_login_live.ex. The same @form assign that was generated by phx.gen.auth can be re-used in this form. The key difference is the form’s action now has a query parameter: action=magic_link

# user_login_live.ex

<div class="flex justify-center mt-8">
  <div class="align-center">OR</div>
</div>

<.simple_form for={@form} id="magic_link_form" action={~p"/users/log_in?_action=magic_link"} phx-update="ignore" class="my-0 py-0">
  <.input field={@form[:email]} type="email" label="Email" required />
  <:actions>
    <.button class="w-full">
      Send me a link <.icon name="hero-envelope" />
    </.button>
  </:actions>
</.simple_form>

The sign in form now has a new action that looks something like:

sign in form

2. Generate a secure sign in token

In the Accounts context, generate and insert a new email token with a token context value of magic_link. This function will be called by the controller when a magic link is requested.

# accounts.ex

@doc """
Generates and delivers a "magic" sign in link to a user's email
"""
def deliver_magic_link(user) do
  {email_token, token} = UserToken.build_email_token(user, "magic_link")
  Repo.insert!(token)
end

Email tokens differ from regular user/session tokens in that the email tokens are hashed. This means even if an attacker gains read access to the database, the original token cannot be reconstructed and is therefore useless to the would-be attacker.

To add support for the new token type and context, add the following module attribute and function head to the UserToken module:

# user_token.ex

# It is very important to keep the magic link token expiry short,
# since someone with access to the email may take over the account.
@magic_link_validity_in_days 1
 
defp days_for_context("magic_link"), do: @magic_link_validity_in_days

There needs to be a way to retrieve users by these new tokens. Add a function to the Accounts context to do so:

# accounts.ex
def get_user_by_email_token(token, context) do
  with {:ok, query} <- UserToken.verify_email_token_query(token, context),
       %User{} = user <- Repo.one(query) do
    user
  else
    _ -> nil
  end
end

Now, once the user hits the Send me a link button the form will be submitted to users/log_in?_action=magic_link. Add a new create/2 function head to handle the incoming request:

We pass a function to the Accounts context so that the Accounts context does not need to reach back into the Web context (to retrieve the endpoint url) - which would have created a circular dependency. Thank you to linusdm from ElixirForum for pointing this out!

# user_session_controller.ex

def create(conn, %{"_action" => "magic_link"} = params) do
  %{"user" => %{"email" => email}} = params
  if user = Accounts.get_user_by_email(email) do
    magic_link_url_fun = &"#{MagicLinkWeb.Endpoint.url()}/users/log_in/#{&1}"

    Accounts.deliver_magic_link(user, magic_link_url_fun)
  end

  # In order to prevent user enumeration attacks, don't disclose whether the email is registered.
  conn
  |> put_flash(:info, "If we find an account for #{email} we'll send a one-time sign-in link")
  |> redirect(to: ~p"/users/log_in")
end

When submitted, the code fetches the current user based on the email address. If found, it invokes Accounts.deliver_magic_link to generate, store, and deliver the email to the user.

Regardless of whether or not the user is found, the user is redirected to the log in page with a success flash message to prevent user enumeration attacks.

3. Create the magic link email template

The UserNotifier module contains the logic for creating and sending emails. Add a deliver_magic_link/2 function that will send the email to the user with the magic sign in link in the body of the message:

# user_notifier.ex

def deliver_magic_link(user, url) do
  deliver(user.email, "Sign in to MagicLink", """
  ==============================
  Hi #{user.email},

  Please use this link to sign in:

  #{url}

  If you didn't request this email, feel free to ignore this.
  ==============================
  """)
end

4. Send the email

Extend the previously implemented Accounts.deliver_magic_link(user, magic_link_url_fun) function to invoke the deliver_magic_link/2 function added in the previous step:

Important: Replace MagicLinkWeb with the name of your project’s web context.

# accounts.ex

@doc """
Generates and delivers a "magic" sign in link to a user's email
"""
def deliver_magic_link(user, magic_link_url_fun) do
  {email_token, token} = UserToken.build_email_token(user, "magic_link")
  Repo.insert!(token)
  
  UserNotifier.deliver_magic_link(user, magic_link_url_fun.(email_token))
end

Now, when the user submits the “send me an email” form, the token is created and the email is sent to the user. Now, for the final piece of the puzzle:

5. Log in the user from the link

Add a new get route in the router to match the link sent in the email:

# router.ex

# Existing route
post "/users/log_in", UserSessionController, :create
# New route
get "/users/log_in/:token", UserSessionController, :create

Add a new create/2 function head to decode the given token and log in the user:

Important: Replace MagicLink with your project’s name.

# user_session_controller.ex

alias MagicLink.Accounts.User

def create(conn, %{"token" => token} = _params) do
  case Accounts.get_user_by_email_token(token, "magic_link") do
    %User{} = user ->
      conn
      |> put_flash(:info, "Welcome back!")
      |> UserAuth.log_in_user(user)

    _ ->
      conn
      |> put_flash(:error, "That link didn't seem to work. Please try again.")
      |> redirect(to: ~p"/users/log_in")
  end
end

The new create/2 function head retrieves the user from their email token and signs them in.

And that’s it! Magic link authentication has now been fully implemented in your app!

See the full repo here

Follow the README instructions on the repo to see the sign in flow