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. ✌️