Passwordless Authentication from Scratch with Phoenix

In this tutorial, I'll show you how I implemented passwordless authentication with Phoenix for Proseful, the beautifully simple blogging platform that I recently launched.

tl;dr: Check out the example repo.

What is passwordless authentication?

Passwordless authentication is exactly that: an authentication flow that lets a user log in without a password. Instead of a password, some other credential is used to authenticate access. For example, a code sent via SMS or a private link sent via email. In this tutorial, we'll use email for our passwordless flow.

So, why would you want to go passwordless? First, there's no need to create and remember yet another password. This can be a convenience to your users when signing up and logging in, especially on mobile. Second, passwordless removes various attack vectors, such as weak passwords, common passwords, brute force attacks and rainbow table attacks (should your data be compromised). As well, there is likely less code to write and maintain, which means less opportunity to mess something up security-wise.

But, isn't this less secure? Passwordless (via email) is no-less secure than using passwords with an email reset flow. If someone has access to the email account, they can log in. Ideally, to help mitigate this, you'll implement two-factor authentication once it makes sense for your app.

Requirements

Before we start coding, let's review our needs:

  • Allow a user to sign up with only an email address.

  • Allow a user to log in with only an email address.

  • Deliver private login links via email.

  • Prevent login links from being used more than once.

  • Expire login links if left unused (e.g. after 15 minutes).

  • Record and remember a user's session once logged in.

  • Allow a user to log out.

Setup

To do this from scratch, we'll start with a fresh Phoenix app. I'm using Phoenix 1.4.8.

mix phx.new passwordless

Follow Phoenix's prompts and instructions to complete the setup and start the app server.

Home page

We'll create a simple home page as a starting point. Create a controller file at lib/passwordless_web/controllers/home_controller.ex:

defmodule PasswordlessWeb.HomeController do
  use PasswordlessWeb, :controller

  def index(conn, _params) do
    render(conn, "index.html")
  end
end

Create a view at lib/passwordless_web/views/home_view.ex:

defmodule PasswordlessWeb.HomeView do
  use PasswordlessWeb, :view
end

Create a template at lib/passwordless_web/templates/home/index.html.exs:

<h1>Home</h1>

<%= link "Sign up", to: "#todo" %>

Note that we'll update the link with the correct path later. I use to: "#todo" elsewhere in this tutorial as well.

And update the router:

scope "/", PasswordlessWeb do
  pipe_through :browser

  get "/", HomeController, :index
end

Visit http://localhost:4000 to ensure the home page works.

User schema

Next, we'll need a User schema for our users. To keep things simple, we'll add only an email field to our user records.

Generate a migration:

mix ecto.gen.migration create_users

Edit the migration:

defmodule Passwordless.Repo.Migrations.CreateUsers do
  use Ecto.Migration

  def up do
    execute "CREATE EXTENSION IF NOT EXISTS citext"

    create table(:users) do
      add :email, :citext, null: false

      timestamps()
    end

    create unique_index(:users, [:email])
  end

  def down do
    drop table(:users)
  end
end

Note that we're using the Postgres citext extension for case-insensitive emails as a convenience. This will allow a user to capitalize their email however they please. An index is added to ensure email uniqueness.

Run the migration:

mix ecto.migrate

Let's define our User schema. We'll use an Accounts context for user functionality. Create a file at lib/passwordless/accounts/user.ex for the schema:

defmodule Passwordless.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string

    timestamps()
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:email])
    |> validate_required([:email])
    |> unique_constraint(:email)
  end
end

Sign up page

Next, let's add a sign up page for our users. We'll need a few methods to work with user records. Rather than dumping all of our logic into a single, bloated context file, we'll define separate modules.

Create a Users module at lib/passwordless/accounts/users.ex:

defmodule Passwordless.Accounts.Users do
  alias Passwordless.Accounts.User
  alias Passwordless.Repo

  def change(%User{} = user) do
    User.changeset(user, %{})
  end

  def create(attrs \\ %{}) do
    %User{}
    |> User.changeset(attrs)
    |> Repo.insert()
  end
end

Create a controller at lib/passwordless_web/controllers/user_controller.ex:

defmodule PasswordlessWeb.UserController do
  use PasswordlessWeb, :controller

  alias Ecto.Changeset
  alias Passwordless.Accounts.User
  alias Passwordless.Accounts.Users

  def new(conn, _params) do
    changeset = Users.change(%User{})

    conn
    |> assign(:changeset, changeset)
    |> render("new.html")
  end

  def create(conn, %{"user" => user_params}) do
    case Users.create(user_params) do
      {:ok, _user} ->
        conn
        |> put_flash(:info, "Signed up successfully.")
        |> redirect(to: Routes.home_path(conn, :index))

      {:error, %Changeset{} = changeset} ->
        conn
        |> assign(:changeset, changeset)
        |> render("new.html")
    end
  end
end

Create a view at lib/passwordless_web/views/user_view.ex:

defmodule PasswordlessWeb.UserView do
  use PasswordlessWeb, :view
end

Create a template at lib/passwordless_web/templates/user/new.html.eex:

<h1>Sign up</h1>

<%= form_for @changeset, Routes.user_path(@conn, :create), fn f -> %>
  <%= if @changeset.action do %>
    <div class="alert alert-danger">
      Oops, something went wrong! Please check the errors below.
    </div>
  <% end %>

  <%= label f, :email %>
  <%= text_input f, :email %>
  <%= error_tag f, :email %>

  <%= submit "Save" %>
<% end %>

<%= link "Back to home", to: Routes.home_path(@conn, :index) %>

Update the router:

scope "/", PasswordlessWeb do
  ...
  resources "/users", UserController, only: [:create, :new]
end

We also need to update the "Sign up" link in the home page template:

<h1>Home</h1>

<%= link "Sign up", to: Routes.user_path(@conn, :new) %>

Visit http://localhost:4000 and make sure a user record is created successfully on sign up.

Login Request schema

Now we need a way to track requests to log in, remove these requests after they've been used, and expire them if not used. For this, we'll define a LoginRequest schema.

Generate a migration:

mix ecto.gen.migration create_login_requests

Edit the migration:

defmodule Passwordless.Repo.Migrations.CreateLoginRequests do
  use Ecto.Migration

  def change do
    create table(:login_requests) do
      add :user_id, references(:users, on_delete: :delete_all), null: false

      timestamps()
    end

    create index(:login_requests, [:user_id])
  end
end

Note that we've used Ecto.Migration.references/2 to add a foreign key constraint to ensure referential integrity of our data. We've also added an index on the user_id foreign key column as best practice.

Run the migration:

mix ecto.migrate

Define the LoginRequest schema:

defmodule Passwordless.Accounts.LoginRequest do
  use Ecto.Schema

  alias Passwordless.Accounts.User

  schema "login_requests" do
    timestamps()

    belongs_to :user, User
  end
end

And update the User schema with the association:

defmodule Passwordless.Accounts.User do
  ...

  alias Passwordless.Accounts.LoginRequest

  schema "users" do
    ...
    has_one :login_request, LoginRequest
  end

  ...
end

Log in page

Next, let's add a page for our users to log in. For this, we need the ability to lookup a user by email. Update the Users module:

defmodule Passwordless.Accounts.Users do
  ...

  def get_by_email(email) do
    Repo.get_by(User, email: email)
  end
end

We also need the ability to create a login request for a user. Create a LoginRequests module atlib/passwordless/accounts/login_requests.ex:

defmodule Passwordless.Accounts.LoginRequests do
  alias Ecto.Multi
  alias Passwordless.Accounts.Users
  alias Passwordless.Repo

  def create(email) do
    with user when not is_nil(user) <- Users.get_by_email(email) do
      {:ok, changes} =
        Multi.new()
        |> Multi.delete_all(:delete_login_requests, Ecto.assoc(user, :login_request))
        |> Multi.insert(:login_request, Ecto.build_assoc(user, :login_request))
        |> Repo.transaction()

      {:ok, changes, user}
    else
      nil -> {:error, :not_found}
    end
  end
end

We're using Ecto.Multi to perform multiple operations within a database transaction. For security purposes, we delete any existing login requests belonging to the user.

Create a controller at lib/passwordless_web/controllers/login_request_controller.ex:

defmodule PasswordlessWeb.LoginRequestController do
  use PasswordlessWeb, :controller

  alias Passwordless.Accounts.LoginRequests

  def new(conn, _params) do
    render(conn, "new.html")
  end

  def create(conn, %{"login_request" => %{"email" => email}}) do
    case LoginRequests.create(email) do
      {:ok, _changes, _user} ->
        conn
        |> put_flash(:info, "We just emailed you a temporary login link. Please check your inbox.")
        |> redirect(to: Routes.home_path(conn, :index))

      {:error, :not_found} ->
        conn
        |> put_flash(:error, "Oops, that email does not exist.")
        |> render("new.html")
    end
  end
end

Create a view at lib/passwordless_web/views/login_request_view.ex:

defmodule PasswordlessWeb.LoginRequestView do
  use PasswordlessWeb, :view
end

Create a template at lib/passwordless_web/templates/login_request/new.html.eex:

<h1>Log in</h1>

<%= form_for @conn, Routes.login_request_path(@conn, :create), [as: :login_request], fn f -> %>
  <%= label f, :email %>
  <%= text_input f, :email %>

  <%= submit "Continue" %>
<% end %>

<%= link "Back to home", to: Routes.home_path(@conn, :index) %>

Update the router:

scope "/", PasswordlessWeb do
  ...
  resources "/login-requests", LoginRequestController, only: [:create, :new]
end

Also, let's add a "Log in" link to the home page template:

<h1>Home</h1>

<%= link "Sign up", to: Routes.user_path(@conn, :new) %> or
<%= link "Log in", to: Routes.login_request_path(@conn, :new) %>

Visit http://localhost:4000 and make sure that a login request record is created for a successful log in.

Deliver login email

We need to deliver an email whenever a user wants to log in. This email includes a link with a signed token that will log the user in.

First, we need a module to sign and verify tokens for login requests. Create a Tokens module at lib/passwordless/accounts/tokens.ex:

defmodule Passwordless.Accounts.Tokens do
  alias Phoenix.Token
  alias PasswordlessWeb.Endpoint

  @login_request_max_age 60 * 15 # 15 minutes
  @login_request_salt "login request salt"

  def sign_login_request(login_request) do
    Token.sign(Endpoint, @login_request_salt, login_request.id)
  end

  def verify_login_request(token) do
    Token.verify(Endpoint, @login_request_salt, token, max_age: @login_request_max_age)
  end
end

Note that Phoenix.Token.verify/4 will return an appropriate error if the token is invalid or has expired.

We'll use Bamboo to deliver emails. Add bamboo to your app's dependencies in mix.exs:

defp deps do
  [
    ...,
    {:bamboo, "~> 1.2"}
  ]
end

Update the dependencies:

$ mix deps.get

Create a Mailer module at lib/passwordless/mailer.ex:

defmodule Passwordless.Mailer do
  use Bamboo.Mailer, otp_app: :passwordless
end

Bamboo needs to be configured with an adapter that determines how emails are delivered. We'll use the Bamboo.LocalAdapter for our development purposes. Update config/config.exs:

...

config :passwordless, Passwordless.Mailer, adapter: Bamboo.LocalAdapter

...

And restart your app server.

Create an Email module at lib/passwordless/email.ex:

defmodule Passwordless.Email do
  use Bamboo.Phoenix, view: PasswordlessWeb.EmailView
  import Bamboo.Email

  alias Passwordless.Accounts.Tokens

  def login_request(user, login_request) do
    new_email()
    |> to(user.email)
    |> from("support@example.com")
    |> subject("Log in to Passwordless")
    |> assign(:token, Tokens.sign_login_request(login_request))
    |> render("login_request.html")
  end
end

Create a view at lib/passwordless_web/views/email_view.ex:

defmodule PasswordlessWeb.EmailView do
  use PasswordlessWeb, :view
end

Create a template at lib/passwordless_web/templates/email/login_request.html.eex:

<p>Hello!</p>

<p>Use the link below to log in to your Passwordless account. <strong>This link expires in 15 minutes.</strong></p>

<p><%= link "Log in to Passwordless", to: "#todo" %></p>

Update the login request controller to deliver the email:

defmodule PasswordlessWeb.LoginRequestController do
  ...

  alias Passwordless.Email
  alias Passwordless.Mailer

  ...

  def create(conn, %{"login_request" => %{"email" => email}}) do
    case LoginRequests.create(email) do
      {:ok, %{login_request: login_request}, user} ->
        user
        |> Email.login_request(login_request)
        |> Mailer.deliver_now()

        ...
    end
  end
end

Here we use the synchronous Bamboo.Mailer.deliver_now/1 to ensure the email was successfully handed off for delivery.

Bamboo comes with a handy plug for viewing emails sent in development. Let's add it to the router:

defmodule PasswordlessWeb.Router do
  ...

  if Mix.env == :dev do
    forward "/sent-emails", Bamboo.SentEmailViewerPlug
  end

  ...
end

Visit http://localhost:4000 and try to log in. Take a look at http://localhost:4000/sent-emails to verify that the login email was sent.

Session schema

Now we need a way to track user sessions. For this, we'll create a Session schema. Generate a migration:

mix ecto.gen.migration create_sessions

Edit the migration:

defmodule Passwordless.Repo.Migrations.CreateSessions do
  use Ecto.Migration

  def change do
    create table(:sessions) do
      add :user_id, references(:users, on_delete: :delete_all), null: false

      timestamps()
    end

    create index(:sessions, [:user_id])
  end
end

Run the migration:

mix ecto.migrate

Create the Session schema at lib/passwordless/accounts/session.ex:

defmodule Passwordless.Accounts.Session do
  use Ecto.Schema

  alias Passwordless.Accounts.User

  schema "sessions" do
    timestamps()

    belongs_to :user, User
  end
end

Update the User schema with the association:

defmodule Passwordless.Accounts.User do
  ...

  alias Passwordless.Accounts.Session

  schema "users" do
    ...
    has_many :sessions, Session
  end

  ...
end

Redeem login request

Next, let's add the ability to redeem a login request for a new session. Update the LoginRequests module:

defmodule Passwordless.Accounts.LoginRequests do
  ...

  alias Passwordless.Accounts.LoginRequest
  alias Passwordless.Accounts.Tokens

  ...

  def redeem(token) do
    with {:ok, id} <- Tokens.verify_login_request(token),
         login_request when not is_nil(login_request) <- Repo.get(LoginRequest, id),
         %{user: user} <- Repo.preload(login_request, :user)
    do
      Multi.new()
      |> Multi.delete_all(:delete_login_requests, Ecto.assoc(user, :login_request))
      |> Multi.insert(:session, Ecto.build_assoc(user, :sessions))
      |> Repo.transaction()
    else
      nil ->
        {:error, :not_found}

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

Here, we delete any existing login requests belonging to the user for security purposes. We're also surfacing errors when a login request no longer exists or has expired.

Update the login request controller:

defmodule PasswordlessWeb.LoginRequestController do
  ...

  def show(conn, %{"token" => token}) do
    case LoginRequests.redeem(token) do
      {:ok, _changes} ->
        conn
        |> put_flash(:info, "Logged in successfully.")
        |> redirect(to: Routes.home_path(conn, :index))

      {:error, :expired} ->
        conn
        |> put_flash(:error, "Oops, that login link has expired.")
        |> render("new.html")

      {:error, :not_found} ->
        conn
        |> put_flash(:error, "Oops, that login link is not valid anymore.")
        |> render("new.html")
    end
  end
end

Update the login request routes:

scope "/", PasswordlessWeb do
  ...
  resources "/login-requests", LoginRequestController, only: [:create, :new, :show], param: "token"
end

We also need to update the "Log in" link in the email template:

...

<p><%= link "Log in to Passwordless", to: Routes.login_request_url(PasswordlessWeb.Endpoint, :show, @token) %></p>

Visit http://localhost:4000 and verify that a session record is created after clicking the link in a login email.

Cookie session storage

We'll use cookie storage to remember the ID of a user's session. Note that behind the scenes, the Plug.Conn signs the cookie to ensure it can’t be tampered with.

Update the login requests controller:

defmodule PasswordlessWeb.LoginRequestController do
  ...

  def show(conn, %{"token" => token}) do
    case LoginRequests.redeem(token) do
      {:ok, %{session: session}} ->
        conn
        |> put_flash(:info, "Logged in successfully.")
        |> put_session(:session_id, session.id)
        |> configure_session(renew: true)
        |> redirect(to: Routes.home_path(conn, :index))

        ...
    end
  end
end

We need the ability to find a session by ID. Create a Sessions module at lib/passwordless/accounts/sessions.ex:

defmodule Passwordless.Accounts.Sessions do
  alias Passwordless.Accounts.Session
  alias Passwordless.Repo

  def get(id) do
    Repo.get(Session, id)
  end
end

Next, we need a method to check if a user is logged in. Create an AuthHelper module at lib/passwordless_web/helpers/auth_helper.ex:

defmodule PasswordlessWeb.AuthHelper do
  alias Plug.Conn
  alias Passwordless.Accounts.Sessions

  def logged_in?(conn) do
    with session_id when not is_nil(session_id) <- Conn.get_session(conn, :session_id),
         session when not is_nil(session) <- Sessions.get(session_id)
    do
      true
    else
      nil -> false
    end
  end
end

For convenience, we'll import the AuthHelper in all of our views. Update lib/passwordless_web:

defmodule PasswordlessWeb do
  ...

  def view do
    quote do
      ...
      import PasswordlessWeb.AuthHelper
      ...
    end
  end

  ...
end

When a user is logged in, we'll show a "Log out" link. Update the home page template:

<h1>Home</h1>

<%= if logged_in?(@conn) do %>
  <%= link "Log out", to: "#todo" %>
<% else %>
  <%= link "Sign up", to: Routes.user_path(@conn, :new) %> or
  <%= link "Log in", to: Routes.login_request_path(@conn, :new) %>
<% end %>

Visit http://localhost:4000 and make sure that a user's session is remembered after logging in.

Logging out

Finally, we'll give users the ability to log out. This is done by deleting the session record and dropping the session cookie.

Update the Sessions module:

defmodule Passwordless.Accounts.Sessions do
  ...

  def delete(session) do
    Repo.delete(session)
  end
end

Create a controller at lib/passwordless_web/controllers/session_controller.ex:

defmodule PasswordlessWeb.SessionController do
  use PasswordlessWeb, :controller

  alias Passwordless.Accounts.Sessions

  def delete(conn, _params) do
    with id when not is_nil(id) <- get_session(conn, :session_id),
         session when not is_nil(session) <- Sessions.get(id),
         {:ok, _session} <- Sessions.delete(session)
    do
      log_out(conn)
    else
      nil -> log_out(conn)
    end
  end

  defp log_out(conn) do
    conn
    |> configure_session(drop: true)
    |> redirect(to: Routes.home_path(conn, :index))
  end
end

Update the router:

scope "/", PasswordlessWeb do
  ...
  resources "/sessions", SessionController, only: [:delete], singleton: true
end

Update the "Log out" link in the home page template:

<h1>Home</h1>

<%= if logged_in?(@conn) do %>
  <%= link "Log out", to: Routes.session_path(@conn, :delete), method: :delete %>
<% else %>

...

Visit http://localhost:4000 and make sure that logging out works as expected.

Next steps

Success! We've built a complete passwordless authentication flow from scratch. That said, there are a few other important pieces you'll need or want to consider:

  • Configure Bamboo with an adapter for an email delivery service.

  • Log a user in automatically after sign up.

  • Write an authentication plug to protect private endpoints.

  • Send cookies only over HTTPS in production.

  • Notify a user when their email address has changed.

  • Rate limit login requests to help prevent abuse.

Check out the final code in the example repo on Github. If you found this tutorial useful or have questions or comments, feel free to hit me up on Twitter.

And… don't forget to check out Proseful. ✌️