Supabase and GitHub App Authentication done right

Ever wondered how to use GitHub to authenticate on your site? Or why your provider_refresh_token was null when using OAuth GitHub authentication for your Supabase backend?

In this article, I share how I was able to configure Supabase and GitHub App (not OAuth app!) to work together. This allows me to authenticate users on my website with a GitHub, and then to use their GitHub provider token to access GitHub. And provider_refresh_token is properly set!

These tokens are not saved by Supabase, so we need to create a table and glue code to save them in a database. This article shows code examples for that.

Enable GitHub authentication on Supabase

Go to Authentication > Providers > GitHub, toggle checkbox.

If you do not have a GitHub App created and have no Client ID/Client Secret, see the next section.

Enter your client ID and secret.

Create GitHub App

First, go to https://github.com/settings/apps and create a new GitHub App.

  1. Specify App name

  2. Homepage URL: <your supabase url instance> such as https://ybiwkmuwzdxxxxxxxxxx.supabase.co

  3. Callback URL: <your supabase url instance>

  4. Make sure Expire user authorization tokens checkbox is toggled.

  5. Disable WebHook (uncheck "Active") temporarily. This has nothing to do with a Supabase.

  6. Permissions > Account Permissions > Email Address enable email read-only permission. Enable other permissions if needed.

Create profile table in a database

Since Supabase does not store provider tokens in its database for security reasons, we should do it ourselves!

Create a new table profile which will store provider_token and provider_refresh_token. Also enable RLS on this table, and provide 2 new policies:

  • User can SELECT their profile.

  • User can UPDATE their profile.

-- Create a `profile` table
DROP TABLE IF EXISTS public.profile;
CREATE TABLE public.profile (
  -- user id
  id UUID REFERENCES auth.users NOT NULL,
  -- add custom attributes - in my case it's a user login
  login VARCHAR NOT NULL,
  -- add tokens, they can be null!
  github_provider_token VARCHAR NULL,
  github_provider_refresh_token VARCHAR NULL,
  PRIMARY KEY (id)
);

-- Enable RLS
ALTER TABLE public.profile
  ENABLE ROW LEVEL SECURITY;

-- User can select their profile.
CREATE POLICY
  "Can only view own profile data."
  ON public.profile
  FOR SELECT
  USING ( auth.uid() = id );

-- User can update their profile (to update tokens)
CREATE POLICY
  "Can only update own profile data."
  ON public.profile
  FOR UPDATE
  USING ( auth.uid() = id );

-- Create a postgres function that will automatically create new `profile`
-- row when new user is authenticated for the first time.
-- This new row will contain only `id` (uuid) and `login` (github username)
-- fields.
DROP FUNCTION IF EXISTS public.create_profile_for_new_user CASCADE;
CREATE FUNCTION
  public.create_profile_for_new_user()
  RETURNS TRIGGER AS
  $$
  BEGIN
    INSERT INTO public.profile (id, login)
    VALUES (
      NEW.id,
      NEW.raw_user_meta_data ->> 'user_name'
    );
    RETURN NEW;
  END;
  $$ LANGUAGE plpgsql SECURITY DEFINER;

-- Create new trigger to call create_profile_for_new_user when new
-- user is authenticated.
DROP TRIGGER IF EXISTS create_profile_on_signup ON auth.users CASCADE;
CREATE TRIGGER
  create_profile_on_signup
  AFTER INSERT ON auth.users
  FOR EACH ROW
  EXECUTE PROCEDURE
    public.create_profile_for_new_user();

At this step, if a new user is authenticated for the first time, new row will be inserted into a table auth.users, then our trigger is executed and new public.profile row is created. Initially, both tokens are null, but we will populate this info later by ourselves.

Setup frontend

It is not important how exactly you authenticate with github provider, in my case I use pre-built @supabase/auth-ui-react component:

"use client"

import * as React from "react"
import { ThemeSupa } from '@supabase/auth-ui-shared'
// https://supabase.com/docs/guides/auth/auth-helpers/nextjs-server-components#creating-a-supabase-client
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
// generated with pnpx supabase@latest gen types typescript --project-id XXXX --schema public > src/generated/database.types.ts
import { type Database } from '@/generated/database.types';
import { Auth } from "@supabase/auth-ui-react";

export function AuthForm() {
    const supabase = createClientComponentClient<Database>();
    return (
        <Auth
            supabaseClient={supabase}
            providers={['github']}
            appearance={{ theme: ThemeSupa }}
            theme="dark"
            showLinks={false}
            // our auth callback route:
            redirectTo={`http://localhost:3000/api/auth/callback`}
            onlyThirdPartyProviders
        />
    )
}

Setup a callback route

Create new file at /src/app/api/auth/callback/route.ts:

// src/app/api/auth/callback/route.ts

// tell bundler this file can be used by a server only.
import "server-only";
import { NextRequest, NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";

export async function GET(req: NextRequest) {
  const cookieStore = cookies();
  const supabase = createRouteHandlerClient({ cookies: () => cookieStore });
  const { searchParams } = new URL(req.url);
  const code = searchParams.get("code");
  if (code) {
    // this call authenticates 
    const { data, error } = await supabase.auth.exchangeCodeForSession(code);
    if (error) {
      // handle error. 
      throw error;
    }

    const { session } = data;

    {  // create new scope to reuse `error` name
      if (!session.provider_token && !session.provider_refresh_token) {
        throw new Error("Both provider_token and provider_refresh_token are missing")
      }

      // save auth token & refresh token for a current user profile.
      const { error } = await supabase
        .from("profile")
        .update({
          github_provider_token: session.provider_token,
          github_provider_refresh_token: session.provider_refresh_token,
        })
        .eq("id", session.user.id)
        .select()
        .single();

      if (error) {
        // handle error.
        throw error;
      }
    }
  }

  return NextResponse.redirect(new URL("/", req.url));
}

Summary

That's it! Hope you find it useful. Please comment if you find any issues, I will fix.

Did you find this article valuable?

Support Bohdan Vanieiev by becoming a sponsor. Any amount is appreciated!