#1 Handle authentication with Phoenix framework
First article of a series about Phoenix and Elixir. This one will be focused on authentication for both HTML and API usage.
Discover the Phoenix Series
#1・Handle HTML & API authentication
#2・Automatic deployment
#3・Pimp my Phoenix
#4・Discord Bot & Stackable Queries
A fresh start
Creating a new web application often means that you will need an authentication logic to handle users and their resources. I offer you today to give a look at Elixir
, Phoenix
framework and phx_auth_gen package to do so.
I will assume that you already know the basics (at least the setup!) of language Elixir and Phoenix framework. If not, I suggest you read the official guide for both of them:
The idea is to create a web application, from scratch, to show you how to handle user logic in both HTML and API ways. And way more! Because as the title of today's article suggests, this is the first one of a series... But for now, let's focus on authentication.
Let's create our project

First, be sure that Elixir
and Phoenix
are installed on your computer. If so, we will create our project by using:
mix phx.new tasty_recipes --binary-id --live
Oh, I forgot to tell you that we will build a website for people who want to share their cooking recipes! Yummy.
The --binary-id
is used to tell Phoenix
that for our models, we don't want simple integers ID but UUIDs instead. Also, --live
means that Phoenix LiveView will be installed and configured in our project. I really advise you to do it now because it can be a little bit tricky to do it later!
Once Phoenix
is done generating all our source code, say yes when asked to fetch and install dependencies. I had some trouble installing nodes modules because of my Node's version. The best way to get everything to work quickly is to be sure that you use Node LTS 14.17.5
(nvm can be helpful here!).
You should now see something like this :
➜ mix phx.new tasty_recipes --binary-id --live
* creating tasty_recipes/config/config.exs
* creating tasty_recipes/config/dev.exs
...
* creating tasty_recipes/assets/static/images/phoenix.png
* creating tasty_recipes/assets/static/robots.txt
Fetch and install dependencies? [Yn] Y
* running mix deps.get
* running cd assets && npm install && node node_modules/webpack/bin/webpack.js --mode development
* running mix deps.compile
We are almost there! The following steps are missing:
$ cd tasty_recipes
Then configure your database in config/dev.exs and run:
$ mix ecto.create
Start your Phoenix app with:
$ mix phx.server
You can also run your app inside IEx (Interactive Elixir) as:
$ iex -S mix phx.server
Setup database
Now that our project is created, before going further, we need to set up our database. Let's say we are using PostgreSQL.
➜ psql
postgres=# CREATE USER tasty WITH ENCRYPTED PASSWORD 'tasty!';
CREATE ROLE
postgres=# CREATE DATABASE tasty_recipes_dev;
CREATE DATABASE
postgres=# GRANT ALL PRIVILEGES ON DATABASE tasty_recipes_dev TO tasty;
GRANT
Nice, our database is created and we also have a user to interact with it.
We should now update the settings config/dev.exs
of our project to use the correct database and user that we just created :
use Mix.Config
# Configure your database
config :tasty_recipes, TastyRecipes.Repo,
username: "tasty",
password: "tasty!",
database: "tasty_recipes_dev",
hostname: "localhost",
show_sensitive_data_on_connection_error: true,
pool_size: 10
...
As the name of the file config/dev.exs
suggests, this is the file where we configure our staging / development environment. There are two other files called prod.exs
and prod.secrets.exs
to handle everything related to production. We will discuss it in another article!
Run server, run!

Now that our database is set up and configured, let's run our server :
➜ mix phx.server
Compiling 13 files (.ex)
Generated tasty_recipes app
[info] Running TastyRecipesWeb.Endpoint with cowboy 2.9.0 at 0.0.0.0:4000 (http)
[info] Access TastyRecipesWeb.Endpoint at http://localhost:4000
webpack is watching the files…
[hardsource:110f7d29] Using 1 MB of disk space.
[hardsource:110f7d29] Writing new cache 110f7d29...
[hardsource:110f7d29] Tracking node dependencies with: package-lock.json.
Hash: 89d1b8f2fdca3182fe0c
Version: webpack 4.46.0
Time: 1215ms
Built at: 08/29/2021 11:05:32 AM
Asset Size Chunks Chunk Names
../css/app.css 11.5 KiB app [emitted] app
../favicon.ico 1.23 KiB [emitted]
../images/phoenix.png 13.6 KiB [emitted]
../robots.txt 202 bytes [emitted]
app.js 353 KiB app [emitted] app
Entrypoint app = ../css/app.css app.js
[0] multi ./js/app.js 28 bytes {app} [built]
[../deps/phoenix/priv/static/phoenix.js] 39.3 KiB {app} [built]
[../deps/phoenix_html/priv/static/phoenix_html.js] 2.21 KiB {app} [built]
[../deps/phoenix_live_view/priv/static/phoenix_live_view.js] 113 KiB {app} [built]
[./css/app.css] 39 bytes {app} [built]
[./js/app.js] 1.43 KiB {app} [built]
+ 3 hidden modules
Child mini-css-extract-plugin node_modules/css-loader/dist/cjs.js!node_modules/sass-loader/dist/cjs.js!css/app.css:
Entrypoint mini-css-extract-plugin = *
[./node_modules/css-loader/dist/cjs.js!./css/phoenix.css] 10.4 KiB {mini-css-extract-plugin} [built]
[./node_modules/css-loader/dist/cjs.js!./node_modules/sass-loader/dist/cjs.js!./css/app.css] 1.88 KiB {mini-css-extract-plugin} [built]
+ 1 hidden module
You can go to http://localhost:4000/
and see the magic, our website is running!
Create the models

Okay, cool, the server is running, the website is working but what now? It's time to create the models that will handle our users and the TastyRecipes™! As mentioned before and because we are not idiot sandwiches, we will use phx_auth_gen
to take care of it.
First, add it to the list of dependencies in mix.exs
:
def deps do
[
{:phx_gen_auth, "~> 0.7", only: [:dev], runtime: false},
...
]
end
Then, install and compile it :
➜ mix do deps.get, deps.compile
Once it is done, let's generate our users' model :
➜ mix phx.gen.auth Accounts User users --binary-id --web html
Planty of fancy things happens here, models are created, templates are updated, new routes are added, etc.--web html
is used to specify to Phoenix
that we want to customize the namespace of our users' module. I like to do it this way to keep the code more readable.
Run the migrations :
➜ mix ecto.migrate
...
12:57:53.846 [info] == Running 20210829103031 TastyRecipes.Repo.Migrations.CreateUsersAuthTables.change/0 forward
12:57:53.848 [info] execute "CREATE EXTENSION IF NOT EXISTS citext"
12:57:53.878 [info] create table users
12:57:53.892 [info] create index users_email_index
12:57:53.897 [info] create table users_tokens
12:57:53.907 [info] create index users_tokens_user_id_index
12:57:53.912 [info] create index users_tokens_context_token_index
12:57:53.917 [info] == Migrated 20210829103031 in 0.0s
If you start again your server, you should see some new links on the index page, Register and Log in. You can navigate through these links and see that all basic features for users are available:
- Sign up
- Email sign up validation
- Sign in
- Forgot password
- Update account settings
- Logout
Since we don't have set up anything to handle emails, if some of them need to be sent, they will simply be printed in the shell where the server is running. Try register and you should see something like this :
...
[debug] QUERY OK db=5.6ms queue=0.6ms idle=1260.1ms
INSERT INTO "users" ("email","hashed_password","inserted_at","updated_at","id") VALUES ($1,$2,$3,$4,$5) ["[email protected]", "$2b$12$/0ztrsXCu8N1dcDft75r9uHD8jTOY1c6E24wLbRZ6bclY4Ks4KJAS", ~N[2021-08-29 11:12:38], ~N[2021-08-29 11:12:38], <<77, 61, 82, 203, 49, 28, 74, 142, 145, 218, 169, 165, 49, 62, 65, 172>>]
[debug] QUERY OK db=3.3ms queue=0.5ms idle=1269.3ms
INSERT INTO "users_tokens" ("context","sent_to","token","user_id","inserted_at","id") VALUES ($1,$2,$3,$4,$5,$6) ["confirm", "[email protected]", <<127, 77, 176, 118, 30, 197, 71, 251, 114, 184, 131, 179, 74, 23, 85, 205, 64, 26, 152, 114, 145, 214, 69, 6, 222, 64, 198, 242, 148, 24, 149, 186>>, <<77, 61, 82, 203, 49, 28, 74, 142, 145, 218, 169, 165, 49, 62, 65, 172>>, ~N[2021-08-29 11:12:38], <<125, 117, 18, 243, 41, 87, 64, 49, 130, 188, 105, 73, 0, 102, 177, 184>>]
[debug]
==============================
Hi [email protected],
You can confirm your account by visiting the URL below:
http://localhost:4000/html/users/confirm/pPktLpywaTh5TnFewBvGhaj1OhhY_LGlie9ebEXpMdw
If you didn't create an account with us, please ignore this.
==============================
...
mix phx.server
shell outputFine, after a few commands, we have a fully working user logic. Awesome!
Yummy! Yummy!
Let's create and migrate our recipes :
➜ mix phx.gen.live Recipes Recipe recipes name description:text owner:references:users --web html
➜ mix ecto.migrate
For the sake of keeping the code and this article simple, we will just say that our recipe consists in having a name
, a description
and a user
that owns the recipe.
Add the new routes to your router :
...
scope "/html/recipes", TastyRecipesWeb.Html, as: :html do
pipe_through [:browser, :require_authenticated_user]
live "/", RecipeLive.Index, :index
live "/new", RecipeLive.Index, :new
live "/:id/edit", RecipeLive.Index, :edit
live "/:id", RecipeLive.Show, :show
live "/:id/show/edit", RecipeLive.Show, :edit
end
...
Note that we addedrequire_authenticated_user
to scope'spipe_through
so only authenticated users can access these routes.
If you visit http://localhost:4000/html/recipes
you should be able to list, show, create, update and delete recipes. That's nice but we have a problem, owner
field is not populated. To overcome this, you need to update the Recipe
changeset and add owner
field to the cast
method:
@doc false
def changeset(recipe, attrs) do
recipe
|> cast(attrs, [:name, :description, :owner])
|> validate_required([:name, :description])
end
Assign the currently logged user to the live view socket in mount
so we will be able to use it later:
def mount(_params, session, socket) do
assigns = [
conn: socket,
recipes: list_recipes(),
current_user: Accounts.get_user_by_session_token(session["user_token"])
]
{:ok, assign(socket, assigns)}
end
Pass the currently logged user to the modal where the recipe form is located:
<%= if @live_action in [:new, :edit] do %>
<%= live_modal @socket, TastyRecipesWeb.Html.RecipeLive.FormComponent,
id: @recipe.id || :new,
title: @page_title,
action: @live_action,
recipe: @recipe,
current_user: @current_user,
return_to: Routes.html_recipe_index_path(@socket, :index) %>
<% end %>
And update the save_recipe
method with action :new
defp save_recipe(socket, :new, recipe_params) do
case Recipes.create_recipe(Map.put(recipe_params, "owner", socket.assigns.current_user.id)) do
{:ok, _recipe} ->
{:noreply,
socket
|> put_flash(:info, "Recipe created successfully")
|> push_redirect(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
As you can see in theform_component.ex
file, there is two methods calledsave_recipe
with the same number of arguments. What kind of sorcery is this?! It's called pattern matching and if you are not in touch with that, I really advise you to check this awesome article written by Mathieu!

If we save a new recipe again, owner
field should now be populated with the user that created it, nice!
You can check this by updating a little bit the index
template:
<h1>Listing Recipes</h1>
<%= if @live_action in [:new, :edit] do %>
<%= live_modal @socket, TastyRecipesWeb.Html.RecipeLive.FormComponent,
id: @recipe.id || :new,
title: @page_title,
action: @live_action,
recipe: @recipe,
current_user: @current_user,
return_to: Routes.html_recipe_index_path(@socket, :index) %>
<% end %>
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>User</th>
<th></th>
</tr>
</thead>
<tbody id="recipes">
<%= for recipe <- @recipes do %>
<tr id="recipe-<%= recipe.id %>">
<td><%= recipe.name %></td>
<td><%= recipe.description %></td>
<td><%= recipe.owner %></td>
<td>
<span><%= live_redirect "Show", to: Routes.html_recipe_show_path(@socket, :show, recipe) %></span>
<span><%= live_patch "Edit", to: Routes.html_recipe_index_path(@socket, :edit, recipe) %></span>
<span><%= link "Delete", to: "#", phx_click: "delete", phx_value_id: recipe.id, data: [confirm: "Are you sure?"] %></span>
</td>
</tr>
<% end %>
</tbody>
</table>
<span><%= live_patch "New Recipe", to: Routes.html_recipe_index_path(@socket, :new) %></span>
And voilà! That's it for the HTML part. We still miss a lot of things for the website to be usable (owner check, design, etc.) but we will see that in the next article, let's focus on API for now!
Oh mighty API, show me your recipes...
For this part, we will use Guardian which is a token-based authentication library for Elixir
applications.
First, add it to the list of dependencies :
def deps do
[
{:guardian, "~> 2.0"},
...
]
end
Then, install and compile it :
➜ mix do deps.get, deps.compile
Following the Guardian documentation, we have to :
[...] create an "implementation module" which includesGuardian
's functionality and the code for encoding and decoding our token's values. To do this, create a module that usesGuardian
and implements thesubject_for_token/2
andresource_from_claims/1
function :
defmodule TastyRecipes.Guardian do
use Guardian, otp_app: :tasty_recipes
def subject_for_token(%{id: id}, _claims) do
# You can use any value for the subject of your token but
# it should be useful in retrieving the resource later, see
# how it being used on `resource_from_claims/1` function.
# A unique `id` is a good subject, a non-unique email address
# is a poor subject.
sub = to_string(id)
{:ok, sub}
end
def subject_for_token(_, _) do
{:error, :reason_for_error}
end
def resource_from_claims(%{"sub" => id}) do
# Here we'll look up our resource from the claims, the subject can be
# found in the `"sub"` key. In above `subject_for_token/2` we returned
# the resource id so here we'll rely on that to look it up.
resource = TastyRecipes.Accounts.get_user!(id)
{:ok, resource}
end
def resource_from_claims(_claims) do
{:error, :reason_for_error}
end
end
And update the configuration :
...
config :tasty_recipes, TastyRecipes.Guardian,
secret_key: "QEijcHVZw9X8lgP/9TJROU+MVyvUMLv/mUY2+Kx7XwXfXeVACQsX4vTfaiLapTLv",
issuer: "tasty_recipes",
ttl: {7, :days}
config :tasty_recipes, TastyRecipesWeb.ApiAuthPipeline,
error_handler: TastyRecipesWeb.ApiAuthErrorHandler,
module: TastyRecipes.Guardian
Note: You can generate your ownsecret_key
withmix phx.gen.secret
Let's create the ApiAuthPipeline
and ApiAuthErrorHandler
that we just defined in the configuration.
defmodule TastyRecipesWeb.ApiAuthPipeline do
use Guardian.Plug.Pipeline, otp_app: :tasty_recipes
plug Guardian.Plug.VerifyHeader, claims: %{"typ" => "access"}
plug Guardian.Plug.EnsureAuthenticated
plug Guardian.Plug.LoadResource, allow_blank: true
end
defmodule TastyRecipesWeb.ApiAuthErrorHandler do
import Plug.Conn
@behaviour Guardian.Plug.ErrorHandler
@impl Guardian.Plug.ErrorHandler
def auth_error(conn, {type, _reason}, _opts) do
body = Jason.encode!(%{message: to_string(type)})
send_resp(conn, 401, body)
end
end
Cool, we now have all we need to start creating our first API route aka sign_in
:
...
pipeline :api_authenticated do
plug TastyRecipesWeb.ApiAuthPipeline
end
scope "/api", TastyRecipesWeb.Api, as: :api do
pipe_through :api
post "/sign_in", SessionController, :create
end
...
And of course, we need to create the SessionController
and the view that renders our response:
defmodule TastyRecipesWeb.Api.SessionController do
use TastyRecipesWeb, :controller
alias TastyRecipes.Accounts
alias TastyRecipes.Accounts.User
alias TastyRecipes.Guardian
def create(conn, %{"email" => nil}) do
conn
|> put_status(401)
|> render("error.json", message: "Wrong credentials")
end
def create(conn, %{"email" => email, "password" => password}) do
case Accounts.get_user_by_email_and_password(email, password) do
%User{} = user ->
{:ok, jwt, _full_claims} = Guardian.encode_and_sign(user, %{})
conn
|> render("user_created.json", user: user, jwt: jwt)
_ ->
conn
|> put_status(401)
|> render("error.json", message: "Wrong credentials")
end
end
end
Everything should be ready now! Let's try it:
➜ http POST localhost:4000/api/sign_in [email protected] password=mypassword
HTTP/1.1 200 OK
cache-control: max-age=0, private, must-revalidate
content-length: 485
content-type: application/json; charset=utf-8
date: Sun, 29 Aug 2021 15:01:40 GMT
server: Cowboy
x-request-id: Fp_PGhi_E5gRoL0AABGF
{
"data": {
"email": "[email protected]",
"token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ0YXN0eV9yZWNpcGVzIiwiZXhwIjoxNjMwODU0MTAxLCJpYXQiOjE2MzAyNDkzMDEsImlzcyI6InRhc3R5X3JlY2lwZXMiLCJqdGkiOiI4NjdlOWJmMi04Nzk3LTRjMTQtODdjOS0xODg0ZWVlMmEyNDkiLCJuYmYiOjE2MzAyNDkzMDAsInN1YiI6ImViMDdlYTQ5LTVkYTctNGRiOC04OGFlLWI0YmJjNWIyZmEwZSIsInR5cCI6ImFjY2VzcyJ9.Sa--cLa-6NRvxT7tJGj8LFmND8gUX2NKQ9_2TkbOzZS2YHldQeGYFo6XCtylmtfEVmMAl8lssnzmkjQCEcGnAg"
},
"message": "Successful log in",
"status": "ok"
}
Note: I am using httpie to test the endpoints.
Awesome, looks like we could authenticate ourselves! We now just need to update a little bit our api_authenticated
plug to fetch user when there is an authenticated API call.
Add the fetch_current_user_api
method in the user_auth
file :
@doc """
Fetch user info linked to JWT .
"""
def fetch_current_user_api(conn, _opts) do
user = conn.private.guardian_default_resource
assign(conn, :current_user, user)
end
Update the api_authenticated
plug :
pipeline :api_authenticated do
plug TastyRecipesWeb.ApiAuthPipeline
plug :fetch_current_user_api
end
Now that we can retrieve our JWT token and use it to retrieve the user linked to it, we need to create the routes and logic to interact with the recipes. Thanks to Pheonix
we can generate it on the fly :
➜ mix phx.gen.json Recipes Recipe recipes name description:text owner:references:users --web api --no-ecto --no-context --no-schema
We use --no-ecto
(database migration), --no-context
and --no-schema
because they were already created in the previous steps. You can add the newly created resource to the router :
scope "/api", TastyRecipesWeb.Api, as: :api do
pipe_through :api_authenticated
resources "/recipes", RecipeController
end
We will also update the create
method to pass the retrieved user from JWT token as owner
:
def create(conn, %{"recipe" => recipe_params}) do
with {:ok, %Recipe{} = recipe} <-
Recipes.create_recipe(Map.put(recipe_params, "owner", conn.assigns.current_user.id)) do
conn
|> put_status(:created)
|> put_resp_header("location", Routes.api_recipe_path(conn, :show, recipe))
|> render("show.json", recipe: recipe)
end
end
Time to try to create a recipe!

➜ http POST http://localhost:4000/api/recipes \
recipe:='{
"name": "Awesome recipe",
"description": "Short but awesome recipe!"
}' \
Authorization:'Bearer eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9....'
HTTP/1.1 201 Created
cache-control: max-age=0, private, must-revalidate
content-length: 120
content-type: application/json; charset=utf-8
date: Sun, 29 Aug 2021 16:00:37 GMT
location: /api/recipes/8031b9a3-ba17-4d55-ae8b-06fd27532ed3
server: Cowboy
x-request-id: Fp_SUb19xXArlWAAAAuG
{
"data": {
"description": "Short but awesome recipe!",
"id": "8031b9a3-ba17-4d55-ae8b-06fd27532ed3",
"name": "Awesome recipe"
}
}
And what about retrieving them all?
➜ http http://localhost:4000/api/recipes \
Authorization:'Bearer eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9....'
HTTP/1.1 200 OK
cache-control: max-age=0, private, must-revalidate
content-length: 736
content-type: application/json; charset=utf-8
date: Sun, 29 Aug 2021 16:01:20 GMT
server: Cowboy
x-request-id: Fp_SW5G1BJQl06MAAAeJ
{
"data": [
{
"description": "Short but awesome recipe!",
"id": "8031b9a3-ba17-4d55-ae8b-06fd27532ed3",
"name": "Awesome recipe"
}
]
}
And that's it! We can now handle authentication & our TastyRecipes™ in both HTML and API way.

You can go further and create more API endpoints for users so they can sign up, update their password and things like that, directly from API, but on our side we will keep it simple!
In the next articles, we will see a few things such as :
- Handle user ownership
- Admin role
- Improve our recipes model
- Implement some real-time features using LiveView
- ...
Don't forget to subscribe if you don't want to miss these updates!
If you have a problem and no one else can help. Maybe you can hire the Kalvad-Team.