- Published on
Integrating GitHub OAuth in Elixir Phoenix - Complete Authentication Tutorial
- Authors
Introduction
Authentication is a core feature in almost every web application today. Whether you’re building a SaaS product, an internal tool, or just experimenting with Phoenix. At some point, you’ll want to know who your users are.
Traditionally, that means managing usernames, passwords, password resets, and hashing algorithms — all of which can easily become complex and error-prone. Instead of reinventing that wheel, we can delegate authentication to trusted providers like GitHub, Google, or Apple. These platforms already handle the hard parts: storing credentials securely, enforcing two-factor authentication, and protecting against brute-force attacks.
In this tutorial, we’ll integrate GitHub OAuth into a brand-new Phoenix application from scratch. We’ll start by setting up our local environment with Docker and PostgreSQL, configure OAuth credentials in GitHub, and use the excellent Überauth library to connect everything.
Once the basics are in place, we’ll implement a login page, store authenticated users in our database, and create a protected home page that only logged-in users can access. Finally, we’ll round it off by adding a simple logout functionality and a pair of custom plugs to handle authentication cleanly across the entire application.
By the end of this tutorial, you’ll have a fully functional GitHub login flow built on top of Phoenix — clean, secure, and ready to extend for real-world use cases.
Setup
Before we start things off, let's create our local setup to make sure we've got everything up and running.
Project and database
Of course, we first need to create a new Phoenix application:
mix phx.new github_auth
After that, navigate into the new folder and add a docker-compose.yml
to configure the PostgreSQL database:
# docker-compose.yaml
services:
db:
image: postgres:16
container_name: github_auth_db
restart: unless-stopped
environment:
POSTGRES_DB: github_auth_dev
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
PGDATA: /var/lib/postgresql/data/pgdata
ports:
- "5432:5432"
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d github_auth_dev"]
interval: 5s
timeout: 3s
retries: 10
volumes:
db_data:
The important part here is that the environment variables POSTGRES_DB
, POSTGRES_USER
, and POSTGRES_PASSWORD
are the same as inside config/dev.exs
, where the database is configured for the development mode:
config :github_auth, GithubAuth.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "github_auth_dev",
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10
Now, spin up your database with Docker:
docker compose up -d
Finally, you can configure the database connection as described in config/dev.exs
:
mix ecto.create
Great! With that in place, we can jump into creating a new GitHub OAuth application.
GitHub OAuth Application
Because this tutorial is about adding GitHub OAuth, we need to create an OAuth Application in GitHub. Follow these steps:
- In your GitHub account, go to Settings (Click on your profile picture and choose Settings)
- On the left, scroll to the bottom and click Developer Settings
- Choose OAuth Apps in the left navigation
- Click New OAuth App at the top
- Type in an Application Name
- Add
http://localhost:4000
to Homepage URL - Add
http://localhost:4000/auth/github/callback
to Authorization callback URL - Click on Register application
- Copy Client ID and paste it somewhere
- Click on Generate a new client secret, copy the Client Secret, and paste it somewhere
Create a new .env
file in the root of your project and add these key-value pairs (add your credentials, of course):
GITHUB_CLIENT_ID=<YOUR_CLIENT_ID>
GITHUB_CLIENT_SECRET=<YOUR_CLIENT_SECRET>
Make sure to add .env
to your .gitignore
.
Now, we're ready to add and configure our dependencies!
Dependencies
To make the authentication work, we need a few dependencies. Add them to your mix.exs
:
defp deps do
[
...
{:ueberauth, "~> 0.10"},
{:ueberauth_github, "~> 0.8"},
{:oauth2, "~> 2.1"},
{:dotenvy, "~> 1.0.0", only: [:dev, :test]}
]
end
Install them with:
mix deps.get
For reference and further reading, here are the docs:
Überauth
Überauth GitHub
OAuth2
Dotenvy
Configuration
Now, we need to tell our Phoenix application which Überauth strategy we're using and which GitHub OAuth application it should use. In your config/runtime.exs
, add the following:
import Dotenvy
# Environment sources
source!([Path.expand("./.env"), System.get_env()])
# Configure Uberauth for GitHub OAuth
config :ueberauth, Ueberauth,
providers: [
github: {Ueberauth.Strategy.Github, [default_scope: "read:user,user:email"]}
]
config :ueberauth, Ueberauth.Strategy.Github.OAuth,
client_id: env!("GITHUB_CLIENT_ID", :string),
client_secret: env!("GITHUB_CLIENT_SECRET", :string)
As you can see, it's pretty straightforward. We're reading our .env
file to extract the values from it, telling ueberauth
that we're using the Github Strategy and passing the credentials from our OAuth app to it.
This is basically everything that should be done in order to configure Überauth, thanks to its lightweight and clear API, one of the reasons it's popular.
Verify setup
To test if our setup was configured successfully, we can run:
mix phx.server
If everything starts up properly without any errors, our setup was successful! You can also visit http://localhost:4000
in your browser and check if the default page of Phoenix shows up properly.
Github OAuth Integration
Now, we've got everything configured, and we can finally start integrating GitHub OAuth into our application. Let's dive in!
User Entity
First, we have to create a new users
table in our database, where we upsert our authenticated users later. There are basically two ways we can achieve this:
- Create
user.ex
and the corresponding migration by hand inpriv/repo
- Use the CLI to set everything up for us
Because I really love how efficient the CLI works, I will choose the second way for this tutorial. But feel free to use the first way. If you're new to Phoenix or Ecto, I would recommend using the first way.
In your terminal, type:
mix phx.gen.schema Accounts.User users provider:string uid:string email:string name:string avatar_url:string access_token:text refresh_token:text token_expires_at:utc_datetime --binary-id
This will create a new file user.ex
in lib/github_auth/accounts
, where the columns are defined. The second argument describes the plural of the entity, which will be used as the table name in the database. With the help of --binary-id
, we tell Ecto to use UUIDs. You can read more on that command in the official docs.
When you open user.ex
it should look like this:
defmodule GithubAuth.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users" do
field :provider, :string
field :uid, :string
field : email, :string
field : name, :string
field :avatar_url, :string
field :access_token, :string
field :refresh_token, :string
field :token_expires_at, :utc_datetime
timestamps(type: :utc_datetime)
end
@doc false
def changeset(user, attrs) do
user
|> cast(attrs, [:provider, :uid, :email, :name, :avatar_url, :access_token, :refresh_token, :token_expires_at])
|> validate_required([:provider, :uid, :email, :name, :avatar_url, :access_token, :refresh_token, :token_expires_at])
end
end
We need to make a little tweak to our columns here. Because access_token
, refresh_token
, and token_expires_at
can be nil
, we need to remove them from the list passed to validate_required/1
.
So, make sure the list looks like this:
|> validate_required([:provider, :uid, :email, :name, :avatar_url])
After that, we also have to modify the corresponding migration, which has been auto-generated for us. Open it under priv/repo/migrations
:
defmodule GithubAuth.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
create table(:users, primary_key: false) do
add :id, :binary_id, primary_key: true
add :provider, :string
add :uid, :string
add : email, :string
add : name, :string
add :avatar_url, :string
add :access_token, :text
add :refresh_token, :text
add :token_expires_at, :utc_datetime
timestamps(type: :utc_datetime)
end
end
end
We also have to tell Ecto that the three columns can be null. We can achieve that by adding null: true
at the end of each column:
add :access_token, :text, null: true
add :refresh_token, :text, null: true
add :token_expires_at, :utc_datetime, null: true
With that in place, we can now run the migration. Make sure that your Docker container is still running with the PostgreSQL database.
mix ecto.migrate
You should see something like:
18:14:08.604 [info] == Running 20251013160029 GithubAuth.Repo.Migrations.CreateUsers.change/0 forward
18:14:08.605 [info] create table users
18:14:08.616 [info] == Migrated 20251013160029 in 0.0s
This means that the migration ran successfully and the new users
table was created successfully. To verify it, open your favorite tool to interact with a database like pgAdmin or the PostgreSQL VSCode extension and check if the table was created with the corresponding columns.
Login Page
The next step would be to create our login page, where we will trigger the login. First of all, we need to add our view module, which embeds our HEEx templates.
Under lib/github_auth_web/controllers
, create a new file called auth_html.ex
:
defmodule GithubAuthWeb.AuthHTML do
use GithubAuthWeb, :html
embed_templates "auth_html/*"
end
This basically tells Phoenix to connect our upcoming AuthController
with our HEEx templates defined in the auth_html
directory.
Next, let's create the template for our login page. For that, create a new file called login.html.heex
inside lib/github_auth_web/controllers/auth_html
:
<div class="flex justify-center items-center w-screen h-screen bg-base-200">
<div class="bg-white p-6 rounded-xl shadow-md text-center">
<h2 class="text-xl font-semibold mb-4 text-base-200">Welcome Back!</h2>
<.link href={~p"/auth/github"} class="btn btn-neutral w-full">
<svg class="h-5 w-5 mr-2" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0.5C5.37 0.5 0 5.87 0 12.5c0 5.3 3.438 9.8 8.205 11.387.6.111.82-.26.82-.577v-2.234c-3.338.726-4.043-1.61-4.043-1.61-.546-1.387-1.334-1.756-1.334-1.756-1.09-.744.083-.73.083-.73 1.205.084 1.84 1.236 1.84 1.236 1.07 1.834 2.807 1.305 3.492.998.108-.776.418-1.305.76-1.606-2.665-.305-5.466-1.333-5.466-5.933 0-1.31.469-2.381 1.236-3.221-.124-.303-.535-1.527.117-3.183 0 0 1.008-.322 3.3 1.23a11.5 11.5 0 0 1 6 0c2.29-1.552 3.297-1.23 3.297-1.23.653 1.656.242 2.88.118 3.183.77.84 1.235 1.911 1.235 3.221 0 4.61-2.804 5.625-5.475 5.922.43.37.812 1.103.812 2.222v3.293c0 .32.218.694.825.576A12.013 12.013 0 0 0 24 12.5C24 5.87 18.63 0.5 12 0.5z"/>
</svg>
Sign in with GitHub
</.link>
</div>
</div>
Nothing fancy right here, but absolutely enough for this tutorial. You will probably receive a warning like no route path for GithubAuthWeb.Router matches "/auth/github", which is perfectly fine for now because we haven't already created our /auth/github
route for now. We will take care of that in the next step.
Routes
Right now, we have Überauth configured properly on the one side and our HTML for the login page on the other side. What we need to do now is to wire both sides together to initiate a login request from our login page and to use GitHub to authenticate the user. When this is done, we receive a success or failure callback where we can react.
Let's configure our routes first. Inside lib/github_auth_web/router.ex
, add the following:
defmodule GithubAuthWeb.Router do
use GithubAuthWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {GithubAuthWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
# 👇 Add :auth pipeline
pipeline :auth do
plug Ueberauth
end
scope "/", GithubAuthWeb do
pipe_through :browser
get "/", PageController, :home
# 👇 Add route for login page
get "/auth/login", AuthController, :login
end
# 👇 Add auth routes
scope "/auth", GithubAuthWeb do
pipe_through [:browser, :auth]
get "/:provider", AuthController, :request
get "/:provider/callback", AuthController, :callback
end
if Application.compile_env(:github_auth, :dev_routes) do
import Phoenix.LiveDashboard.Router
scope "/dev" do
pipe_through :browser
live_dashboard "/dashboard", metrics: GithubAuthWeb.Telemetry
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
end
We first add a new pipeline :auth
, where we use the plug from ueberauth
. Then we instantiate our /auth/login
route to render our HTML template for the login page. After that, we added a new scope for our authentication routes. The route /auth/:provider
will be called from our login page, and /auth/:provider/callback
is the route where we receive the result from GitHub. If you can remember, it was the route we added to the GitHub OAuth app as the callback URL earlier.
The routes are a bit useless right now, because our AuthController
doesn't exist yet. Let's fix that and create a new file auth_controller.ex
under lib/github_auth_web/controllers
:
defmodule GithubAuthWeb.AuthController do
use GithubAuthWeb, :controller
def login(conn, _params), do: render(conn, :login)
def request(conn, _params), do: redirect(conn, to: ~p"/auth/login")
def callback(conn, _params), do: redirect(conn, to: ~p"/")
end
There is no business logic right now. We will take care of that in the next step. The current controller code is just for now to have a running application. If you run mix phx.server
, you can see that the development server spins up properly. You can also visit the login page and click on the button. It should redirect you to authorize your GitHub OAuth App to connect your chosen GitHub account. After that, it should redirect you to the default home page of Phoenix.
That's proof for now that the authentication itself runs properly. But we're still missing some things in our application. We're not configuring a session right now and don't upsert the GitHub user in our database. We also don't have error handling in place when the authentication failed or was canceled.
Let's fix that in the next step!
Business Logic
Before we add the corresponding business logic, let's think about what we want to achieve:
- User clicks on the login button
- A request is made to our GitHub OAuth application
- User will be redirected to the OAuth flow by GitHub
- If authentication was made, our
/auth/github/callback
route will be triggered - When everything was successful, receive the user from GitHub in that route and look into our database
- If the user doesn't exist, we insert him
- If he already exists, we update him (e.g.,
access_token
or other updated values from GitHub) - We configure a Phoenix session with that particular user
- Redirection to the home page
In order to add business logic and keep our controller lightweight, we need to create a context for that. Create a file called accounts.ex
inside lib/github_auth
and add this code:
defmodule GithubAuth.Accounts do
import Ecto.Query, warn: false
alias GithubAuth.Repo
alias GithubAuth.Accounts.User
alias Ueberauth.Auth
def upsert_user(%Auth{} = auth, provider) do
attrs = %{
provider: provider,
uid: to_string(auth.uid),
email: auth.info.email,
name: auth.info.name || auth.info.nickname,
avatar_url: auth.info.image,
access_token: auth.credentials.token,
refresh_token: auth.credentials.refresh_token,
token_expires_at:
(auth.credentials.expires_at && DateTime.from_unix!(auth.credentials.expires_at)) || nil
}
case Repo.get_by(User, provider: provider, uid: attrs.uid) do
nil ->
%User{}
|> User.changeset(attrs)
|> Repo.insert()
%User{} = user ->
user
|> User.changeset(attrs)
|> Repo.update()
end
end
end
Now, we can update our callback/2
function in our AuthController
:
defmodule GithubAuthWeb.AuthController do
use GithubAuthWeb, :controller
# 👇 Import necessary modules
alias Ueberauth.Auth
alias GithubAuth.Accounts
def login(conn, _params), do: render(conn, :login)
def request(conn, _params), do: redirect(conn, to: ~p"/auth/login")
# 👇 Add new logic for successful authentication
def callback(%{assigns: %{ueberauth_auth: %Auth{} = auth}} = conn, _params) do
provider = to_string(auth.provider)
case Accounts.upsert_user(auth, provider) do
{:ok, user} ->
conn
|> put_session(:user_id, user.id)
|> configure_session(renew: true)
|> put_flash(:info, "Welcome, #{user.name || user.email || "User"}!")
|> redirect(to: ~p"/")
{:error, reason} ->
IO.inspect(reason)
conn
|> put_flash(:error, "Github login failed")
|> redirect(to: ~p"/auth/login")
end
end
# 👇 Add new logic for failed authentication
def callback(%{assigns: %{ueberauth_failure: _failure}} = conn, _params) do
conn
|> put_flash(:error, "Github login canceled")
|> redirect(to: ~p"/auth/login")
end
end
That's basically it! If the upsert was successful, we configure a new session with our user. By default, the session is valid as long as the user closes the browser window. You can see and alter the configuration under lib/github_auth_web/endpoint.ex
under @session_options
. We will also add a logout functionality later, where we will also kill the session again.
After that, we redirect the user to the home page. If something went wrong in the upsert or in the authentication itself, we will redirect him to /auth/login
again and render an error message.
Test it out! Try to log in and see if your user was stored properly in the database with the correct data.
Let's take this one step further and make it more realistic. We will protect the home page because that's the whole purpose of adding authentication. To have certain pages that are only accessible by authenticated users. We will take a look at that in the next chapter!
Protected Pages
Currently, we've got two pages in our application. The default home page and the login page we've created earlier. Right now, there is no guard or anything like that on the home page, which means that our authentication is still a bit useless. So, we'll take care of that in this chapter here. Furthermore, we will make sure the login page remains accessible to unauthenticated users.
Protect Home Page
First of all, to decide whether a user can visit our home page or not, we have to find out if he has a valid session currently. To make authentication cleaner and reusable across the whole app, we create two custom plugs.
A plug in Phoenix is a small, composable module that can modify or inspect the request/response as it flows through the application, kind of like middleware, but more lightweight and powerful. It’s what Phoenix itself uses under the hood for things like sessions, CSRF protection, or routing. You can read more about plugs in the official docs.
Create a new folder called plugs
in lib/github_auth_web
. Create two files in that new folder fetch_current_user.ex
and require_auth.ex
. Add the following code to the files:
# fetch_current_user.ex
defmodule GithubAuthWeb.Plugs.FetchCurrentUser do
import Plug.Conn
alias GithubAuth.Accounts
def init(opts), do: opts
def call(conn, _opts) do
current_user =
conn
|> get_session(:user_id)
|> case do
nil -> nil
id -> Accounts.get_user(id)
end
assign(conn, :current_user, current_user)
end
end
FetchCurrentUser
runs on every request and checks if there's a user_id
stored in the session. If found, it loads that user from the database and assigns it to conn.assigns.current_user
, making the logged-in user available everywhere in controllers or templates.
Let's also implement the new get_user/1
function inside lib/github_auth/accounts.ex
:
defmodule GithubAuth.Accounts do
...
# 👇 Add new function
def get_user(id), do: Repo.get(User, id)
end
defmodule GithubAuthWeb.Plugs.RequireAuth do
import Plug.Conn
import Phoenix.Controller, only: [redirect: 2]
def init(opts), do: opts
def call(%{assigns: %{current_user: nil}} = conn, _opts) do
conn
|> redirect(to: "/auth/login")
|> halt()
end
def call(conn, _opts), do: conn
end
RequireAuth
then protects routes that should only be accessible to authenticated users. If no current_user
is present, it halts the request and redirects the visitor to the login page.
Together, these two plugs ensure that protected pages can only be accessed by logged-in users, while keeping user data conveniently available throughout the app.
To let FetchCurrentUser
run properly on every incoming request, we need to extend our :browser
pipeline in lib/github_auth_web/router.ex
:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {GithubAuthWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
# 👇 Add new plug
plug GithubAuthWeb.Plugs.FetchCurrentUser
end
Now comes the magic part! To protect our home page, we need to use the RequireAuth
plug in the corresponding controller, which is the PageController.ex
:
defmodule GithubAuthWeb.PageController do
use GithubAuthWeb, :controller
# 👇 Add new plug
plug GithubAuthWeb.Plugs.RequireAuth when action in [:home]
def home(conn, _params) do
render(conn, :home)
end
end
Since we've only got one route in our PageController
, we could also remove when action in [:home]
, but I think it's a bit more declarative and future-proof when we're about to add more pages. With that in place, we could also add pages that might not need any protection. In that case, we could leave the list as it is. If there are pages that need protection, we could just add them to the list.
Now, it's time to test it out. Close the browser or just remove the _github_auth_key
from your cookies and try to visit http://localhost:4000
. In that case, you should automatically be redirected to http://localhost:4000/auth/login
. Then, try to log in and see that you can visit the home page again. That's great proof that the protection of the home page works properly!
Redirect from the login page
Right now, we can also visit http://localhost:4000/auth/login
, even if we're already authenticated. This is not wrong by default, but a little inconvenient in my opinion. Let's fix that by redirecting to the home page when an authenticated user visits the login page.
In lib/github_auth_web/controllers/auth_controller.ex
adjust the login/2
function to look like this:
def login(%{assigns: %{current_user: current_user}} = conn, _params) do
if current_user do
redirect(conn, to: ~p"/")
else
render(conn, :login)
end
end
Thanks to our FetchCurrentUser
plug, we will receive the current_user
who is requesting our login page. If he's authenticated, he will be present on the request. So, we redirect automatically to the home page. If not, we still render the login page to let him log in properly.
Great! Now, with that in place, we've already have a properly functioning authentication in our application. But what would be an authentication mechanism when a user can't log out? Let's fix that in the next chapter!
Logout
To add a logout mechanism, we just have to add the HTML for it to our home page and configure a new /auth/logout
route.
In your lib/github_auth_web/page_html/home.html.heex
, add this HTML for the logout button:
<.form for={%{}} as={:logout} action={~p"/auth/logout"} method="delete">
<button class="btn btn-ghost">Logout</button>
</.form>
It doesn't matter where. Just pick a place that's recognizable to you.
Next, let's add the new logout route in the router.ex
:
scope "/auth", GithubAuthWeb do
pipe_through [:browser, :auth]
get "/:provider", AuthController, :request
get "/:provider/callback", AuthController, :callback
delete "/logout", AuthController, :logout
end
Then, in lib/github_auth_web/controllers/auth_controller.ex
, add the new route handler function:
def logout(conn, _params) do
conn
|> configure_session(drop: true)
|> redirect(to: ~p"/auth/login")
end
Nothing fancy right here. We just drop the current session and redirect to our home page after that. Feel free to test the logout functionality and see if you can't visit the protected home page after logging out!
Conclusion
We’ve now built a complete authentication flow in Phoenix using GitHub OAuth. From the initial project setup to a fully working login, session, and logout mechanism. Along the way, we configured Überauth to handle the OAuth process, created a user schema to persist authenticated users, and wired up our controller to handle success and failure cases gracefully.
To make the authentication experience more realistic and secure, we also introduced two custom plugs, FetchCurrentUser
and RequireAuth
. Together, they give our application a clean and reusable way to identify the current user and to guard protected pages. This pattern not only keeps controllers lightweight but also mirrors how real-world Phoenix applications handle user sessions at scale.
With this foundation in place, your app is already following best practices for authentication:
- No need to manage passwords or hashing yourself
- Secure sessions handled by Phoenix
- Clear separation of responsibilities between authentication (GitHub) and authorization (your app)
From here, you can take the next steps to make it production-ready. For example, by adding multiple OAuth providers (Google, Apple, etc.), persisting sessions across restarts, or implementing user roles and permissions.
What makes Phoenix and Überauth so enjoyable to work with is how little boilerplate is needed to achieve something this powerful. In just a few modules and templates, you’ve built a clean, extensible, and secure authentication system, one that’s easy to maintain and ready to grow with your application.
I hope you've had as much fun as me writing this post. See you next time!