Launch pricing 50% off templates until 21 Sep

From the blog

Phoenix OAuth Login: Add Google and GitHub Sign-In to phx.gen.auth

By Liam Killingback ·

Phoenix OAuth Login: Add Google and GitHub Sign-In to phx.gen.auth

mix phx.gen.auth gives you a solid email and password login in about ninety seconds. Then the first real user lands on your sign-up page, sees an empty password field, and leaves. Social login is not a nice-to-have any more: for a developer tool, “Continue with GitHub” is often the only button people want to press.

The good news is that Phoenix OAuth login sits neatly on top of what phx.gen.auth already generated. You are not replacing the auth system. You are adding a second way to prove an email address, and then reusing the same session code that the password form uses.

This guide adds Google and GitHub sign-in to a stock Phoenix 1.7 app with phx.gen.auth installed. It covers the parts that tutorials usually skip: the hashed_password column that refuses to be null, why matching users on email alone is an account takeover waiting to happen, and how to test the callback without talking to Google.

What we are building

Three moving parts:

  1. Ueberauth and two strategy packages handle the redirect dance with Google and GitHub.
  2. A user_identities table records which provider account belongs to which local user, so a person can sign in with either provider and land in the same account.
  3. A controller turns a successful callback into a session using UserAuth.log_in_user/3, the function phx.gen.auth already wrote for you.

Everything the password flow does after login (remember me, session renewal, the :require_authenticated_user plug, LiveView on_mount hooks) keeps working untouched.

Step 1: Add the dependencies

# mix.exs
defp deps do
  [
    # ... your existing deps
    {:ueberauth, "~> 0.10"},
    {:ueberauth_google, "~> 0.12"},
    {:ueberauth_github, "~> 0.8"}
  ]
end

Run mix deps.get. Ueberauth is the framework, and each strategy is a small package that knows one provider’s quirks.

Step 2: Configure the providers

# config/config.exs
config :ueberauth, Ueberauth,
  providers: [
    google: {Ueberauth.Strategy.Google, [default_scope: "email profile"]},
    github: {Ueberauth.Strategy.Github, [default_scope: "user:email"]}
  ]

Then the credentials, which belong in runtime config so they come from the environment in production:

# config/runtime.exs
config :ueberauth, Ueberauth.Strategy.Google.OAuth,
  client_id: System.get_env("GOOGLE_CLIENT_ID"),
  client_secret: System.get_env("GOOGLE_CLIENT_SECRET")

config :ueberauth, Ueberauth.Strategy.Github.OAuth,
  client_id: System.get_env("GITHUB_CLIENT_ID"),
  client_secret: System.get_env("GITHUB_CLIENT_SECRET")

Two scope notes worth the thirty seconds they save you later:

  • default_scope: "email profile" on Google is the minimum that returns an email address. Ask for less and auth.info.email comes back nil.
  • default_scope: "user:email" on GitHub is not optional. Most GitHub users keep their email private, and without that scope ueberauth_github cannot fetch the verified primary address. You get a nil email and a confusing bug report a week later.

In each provider’s console, register the callback URLs. For local development that is http://localhost:4000/auth/google/callback and http://localhost:4000/auth/github/callback, and for production the same paths on your real domain. Google is strict about exact matches, including the scheme and the trailing path.

Step 3: Routes and the controller

# lib/my_app_web/router.ex
scope "/auth", MyAppWeb do
  pipe_through :browser

  get "/:provider", OAuthController, :request
  get "/:provider/callback", OAuthController, :callback
end

The controller:

defmodule MyAppWeb.OAuthController do
  use MyAppWeb, :controller

  plug Ueberauth

  alias MyApp.Accounts
  alias MyAppWeb.UserAuth

  # The Ueberauth plug intercepts this action and redirects to the provider,
  # so the body never actually runs. It exists so the route has a target.
  def request(conn, _params), do: conn

  def callback(%{assigns: %{ueberauth_failure: failure}} = conn, _params) do
    message =
      failure.errors
      |> Enum.map_join(", ", & &1.message)

    conn
    |> put_flash(:error, "Sign-in failed: #{message}")
    |> redirect(to: ~p"/users/log_in")
  end

  def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do
    case Accounts.user_from_oauth(auth) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "Welcome back.")
        |> UserAuth.log_in_user(user, %{})

      {:error, :email_not_verified} ->
        conn
        |> put_flash(:error, "That #{auth.provider} account has no verified email address.")
        |> redirect(to: ~p"/users/log_in")

      {:error, _reason} ->
        conn
        |> put_flash(:error, "We could not sign you in. Please try again.")
        |> redirect(to: ~p"/users/log_in")
    end
  end
end

UserAuth.log_in_user/3 is the function phx.gen.auth generated. It renews the session, writes the token, and redirects to wherever the user was heading. Reusing it is the whole trick: your OAuth users get exactly the same session handling as your password users, including the remember-me cookie if you pass %{"remember_me" => "true"}.

Handling %Ueberauth.Failure{} in its own clause matters more than it looks. A user who clicks “Cancel” on Google’s consent screen comes back through the callback with a failure, and without that clause you get a FunctionClauseError and a 500 page instead of a flash message.

Step 4: The gotcha, hashed_password is not null

Here is where the first real attempt usually dies. The migration phx.gen.auth wrote looks like this:

add :hashed_password, :string, null: false

An OAuth user has no password. You have two options, and they are not equally good.

Option A: make the column nullable. Honest, but it means User.registration_changeset/2 and every password path has to tolerate a nil hash, and a user who later sets a password needs a separate flow. More edits than it first appears.

Option B: generate a random, unusable password. One line, no schema change, and the user can always go through the normal “forgot password” flow later if they want a password too:

defp oauth_registration_changeset(email) do
  password = 32 |> :crypto.strong_rand_bytes() |> Base.url_encode64()

  %User{}
  |> User.registration_changeset(%{email: email, password: password})
  |> Ecto.Changeset.put_change(
    :confirmed_at,
    NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
  )
end

Option B is what most production apps end up doing. Nobody knows the password, including the user, and Bcrypt.verify_pass/2 against a 43 character random string will not be guessed.

Note the confirmed_at too. Google and GitHub have already verified the address, so sending your own confirmation email would be theatre. Set it and let the user straight in.

Step 5: Do not match users on email alone

The tempting version of user_from_oauth/1 is four lines: read auth.info.email, find or create a user with that email, log them in. It is also a documented way to get your users’ accounts stolen.

The problem is that not every provider verifies the email it hands you. If an attacker can create an account at some OAuth provider you support, set its email to victim@example.com, and your app trusts that string, they are now signed in as the victim. This is a real class of bug and it has hit real companies.

Two defences, and you want both.

Check the verified flag before you trust the address:

defp verified_email(%Ueberauth.Auth{provider: :google} = auth) do
  raw = get_in(auth.extra.raw_info, [:user]) || %{}

  if raw["email_verified"] in [true, "true"] and is_binary(auth.info.email) do
    {:ok, auth.info.email}
  else
    {:error, :email_not_verified}
  end
end

# ueberauth_github only returns the primary address, and only when it is
# verified, so the presence of the string is the check.
defp verified_email(%Ueberauth.Auth{provider: :github, info: %{email: email}})
     when is_binary(email),
     do: {:ok, email}

defp verified_email(_auth), do: {:error, :email_not_verified}

Key the identity on the provider’s stable id, not the email. People change their GitHub email. They should not lose their account when they do.

defmodule MyApp.Repo.Migrations.CreateUserIdentities do
  use Ecto.Migration

  def change do
    create table(:user_identities) do
      add :user_id, references(:users, on_delete: :delete_all), null: false
      add :provider, :string, null: false
      add :uid, :string, null: false
      add :email, :citext

      timestamps(type: :utc_datetime)
    end

    create unique_index(:user_identities, [:provider, :uid])
    create index(:user_identities, [:user_id])
  end
end

Step 6: The context function

defmodule MyApp.Accounts do
  # ... the functions phx.gen.auth generated

  alias MyApp.Accounts.{User, UserIdentity}

  def user_from_oauth(%Ueberauth.Auth{} = auth) do
    with {:ok, email} <- verified_email(auth) do
      provider = to_string(auth.provider)
      uid = to_string(auth.uid)

      Repo.transaction(fn ->
        case Repo.get_by(UserIdentity, provider: provider, uid: uid) do
          %UserIdentity{} = identity ->
            Repo.preload(identity, :user).user

          nil ->
            user = get_user_by_email(email) || Repo.insert!(oauth_registration_changeset(email))

            Repo.insert!(%UserIdentity{
              user_id: user.id,
              provider: provider,
              uid: uid,
              email: email
            })

            user
        end
      end)
    end
  end
end

Read the three branches, because they are the three cases you actually have:

  1. Returning social user. The identity row exists, so you load their user. Their email could have changed at the provider and it does not matter.
  2. Existing password user signing in with Google for the first time. get_user_by_email/1 finds them, and you link a new identity to the account they already have. No duplicate account, no “this email is taken” error.
  3. Brand new user. Insert both rows in one transaction.

That linking step in case 2 is only safe because of the verified-email check in step 5. Linking on an unverified address is exactly the takeover described above. The two pieces are one decision, not two.

Step 7: The buttons

<div class="mt-6 space-y-3">
  <.link
    href={~p"/auth/github"}
    class="flex w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 px-4 py-2.5 font-semibold hover:bg-zinc-50"
  >
    Continue with GitHub
  </.link>

  <.link
    href={~p"/auth/google"}
    class="flex w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 px-4 py-2.5 font-semibold hover:bg-zinc-50"
  >
    Continue with Google
  </.link>
</div>

Use href, not navigate. A navigate link is a LiveView client-side push and it cannot leave your origin, so the redirect to Google silently does nothing. This costs people an hour surprisingly often.

Step 8: Testing the callback without talking to Google

You do not want your test suite making HTTP calls to an OAuth provider. Since the controller reads conn.assigns.ueberauth_auth, you can build that struct yourself and call the action function directly, which skips the plug Ueberauth that would otherwise try to complete a real callback.

defmodule MyAppWeb.OAuthControllerTest do
  use MyAppWeb.ConnCase, async: true

  import MyApp.AccountsFixtures
  import Plug.Test, only: [init_test_session: 2]

  defp auth(email, uid) do
    %Ueberauth.Auth{
      provider: :github,
      uid: uid,
      info: %Ueberauth.Auth.Info{email: email}
    }
  end

  test "links a provider account to an existing user", %{conn: conn} do
    user = user_fixture()

    conn =
      conn
      |> init_test_session(%{})
      |> Phoenix.ConnTest.fetch_flash()
      |> Plug.Conn.assign(:ueberauth_auth, auth(user.email, "gh_1"))
      |> MyAppWeb.OAuthController.callback(%{})

    assert Plug.Conn.get_session(conn, :user_token)
    assert [identity] = MyApp.Repo.all(MyApp.Accounts.UserIdentity)
    assert identity.user_id == user.id
  end

  test "creates a confirmed user on first sign-in", %{conn: conn} do
    conn
    |> init_test_session(%{})
    |> Phoenix.ConnTest.fetch_flash()
    |> Plug.Conn.assign(:ueberauth_auth, auth("new@example.com", "gh_2"))
    |> MyAppWeb.OAuthController.callback(%{})

    user = MyApp.Accounts.get_user_by_email("new@example.com")
    assert user.confirmed_at
  end
end

Then test Accounts.user_from_oauth/1 directly for the interesting cases: an unverified Google email returns {:error, :email_not_verified}, and two callbacks with the same uid produce one user and one identity row.

Step 9: Before you ship

A short checklist, all of it learned the hard way:

  • Register the production callback URL in both consoles. A missing redirect URI is the single most common “it worked locally” failure.
  • Check the scheme behind a proxy. On Fly.io or any load balancer terminating TLS, the callback URL Ueberauth builds can come out as http:// unless your endpoint has url: [scheme: "https", host: ..., port: 443] set in runtime config. Google rejects the mismatch.
  • Leave CSRF protection on. Ueberauth sends a state parameter and verifies it on the way back, which is why the /auth routes must go through the :browser pipeline with its session.
  • Google needs verification before it will show your consent screen to users outside your own organisation. Start that review before launch week, not during it.
  • Decide what happens on unlink. If a user removes their only identity and has no usable password, they are locked out. Either block the last unlink or send a password reset first.

Where this leaves you

Roughly 150 lines: two routes, one controller, one context function, one table. Your password flow is untouched, your session handling is shared, and a developer landing on your sign-up page can be inside the app in one click.

If you would rather not wire this yourself, phx_saas ships Google and GitHub sign-in already linked to phx.gen.auth, with the identity table, the verified-email checks and the tests in place.

The natural next steps once people can actually get in: send the welcome email off the request path with Oban background jobs, and decide what each account is allowed to do with plan-based feature gating and entitlements. If the product charges by usage rather than by seat, Aurora Meter handles the counting and the plan limits as a dependency in your own app.

Building more than one Phoenix product? The Builder Pass gives you every template, including the ones that ship this auth setup, for a single lifetime price.