Published on

Integrating GitHub OAuth in Elixir Phoenix - Complete Authentication Tutorial

Authors
  • avatar

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:

  1. In your GitHub account, go to Settings (Click on your profile picture and choose Settings)
  2. On the left, scroll to the bottom and click Developer Settings
  3. Choose OAuth Apps in the left navigation
  4. Click New OAuth App at the top
  5. Type in an Application Name
  6. Add http://localhost:4000 to Homepage URL
  7. Add http://localhost:4000/auth/github/callback to Authorization callback URL
  8. Click on Register application
  9. Copy Client ID and paste it somewhere
  10. 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:

  1. Create user.ex and the corresponding migration by hand in priv/repo
  2. 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:

  1. User clicks on the login button
  2. A request is made to our GitHub OAuth application
  3. User will be redirected to the OAuth flow by GitHub
  4. If authentication was made, our /auth/github/callback route will be triggered
  5. When everything was successful, receive the user from GitHub in that route and look into our database
  6. If the user doesn't exist, we insert him
  7. If he already exists, we update him (e.g., access_token or other updated values from GitHub)
  8. We configure a Phoenix session with that particular user
  9. 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!