Authentication in Next.js with Supabase Auth and PKCE

Next.jsSupabaseAuthenticationPKCE
(updated )
16 min read
Authentication in Next.js with Supabase Auth and PKCE

Background

Seems like just the other day I published an update to my original article on using Supabase Auth with Next.js, and yet here we are with the 3rd installment in the series.

So why another article? Well, as much as I was being a bit tongue-in-cheek about libraries changing all the time, it certainly seems to be the case with Supabase. Their Next.js Auth Helpers has recently undergone several significant updates, and they are worth talking about.

On Next.js front, the App Router is now stable in release 13.4, which means a lot more projects starting to use React Server Components. Having been spending more time with RSC's myself, I'm finding certain patterns to work well, and wanted to share how I've applied those in this project.

As the saying goes, "third time's the charm"? I sure hope so 😅

November 2023 Update
Narrator: "It was not." 🙃 This guide will stay on Auth Helpers, I'm not refactoring this for the 4th time.

Overview

A few months ago Supabase introduced support for Proof Key for Code Exchange flow (or PKCE for short, pronounced "pixy").

PKCE provides applications with a more robust and secure authorization process, protecting against various types of attacks and vulnerabilities that can arise in public client environments.

The PKCE flow introduces a code verifier (a randomly generated secret) and a code challenge (the hash of the code verifier). The authorization code is returned as a query parameter so it's accessible on the server.

PKCE Flow (credit:
Supabase)PKCE Flow (credit: Supabase)

Learn more about Proof Key for Code Exchange authentication flow here

Alongside this came release 0.7.0 of the Next.js Auth Helpers library, which introduced some breaking changes in order to support the new PKCE flow.

In this article, I'm going to share with you the patterns I've landed on for using PKCE with Supabase Auth in Next.js 13+. The final solution ends up being much cleaner than the previous iteration, thanks to the recent updates in Supabase and the power of React Server Components.

As a recap (or if you're reading this for the first time), here is what we're building:

App ScreensApp Screens

The project has two authenticated pages - Home and Profile. Unauthenticated users can Sign In, Sign Up, Reset Password and Update Password. All of this is powered by Next.js app router, with usage of both Client and Server Components, and Supabase handling all of the authentication related functionality. Forms are built using Formik and Yup for field validation.

Page Structure

Aiming to take full advantage of RSC's, I've decided to have each authentication flow have its own route (ie. /sign-in, /sign-up, /reset-password, etc.). This is different from the previous implementation, where the Home route (/) was rendering both authenticated and unauthenticated state.

One of the reasons for this is to have the Reset and Update Password flow work with PKCE. I've also found this approach to result in cleaner page code, without the extra logic for determining which view to show.

So, our page routes will be:

  • / (authenticated Home page)
  • /profile (authenticated Profile page)
  • /sign-in (Sign In page)
  • /sign-up (Sign Up page)
  • /reset-password (Reset Password page)
  • /update-password (Update Password page)

Auth Forms

In the previous iteration of this guide, the individual auth forms were provided in a single <Auth /> component, which had some internal state and used a view prop for showing a specific form (or "view").

Since each authentication flow will now have its own unique route, there isn't really a need for this single <Auth /> component anymore. We can simply use the individual form components (<SignIn />, <SignUp />, etc.) instead:

src/app/sign-in/page.js
import SignIn from 'src/components/Auth/SignIn';

export default async function SignInPage() {
  // ...
  return <SignIn />;
}

The individual auth form components can be found in GitHub. These were built using Formik, following one of their examples in the docs. See the previous post for more details.

Supabase Client

As mentioned earlier, the Supabase Auth Helpers library for Next.js release 0.7.0 brought with it some substantial changes. In particular, these changes affected how we create and use the Supabase Client. Let's take a look at what those are and how they affect our project structure.

Client Components

One of the big changes is that Client Component Supabase clients are now singleton instances, which was not the case before. Previously, we had to ensure that a single instance of Supabase was shared across Client Components. We did that using React Context and the AuthProvider component.

Now, thanks to the clients being singletons, we can simply import createClientSupabaseClient and call it wherever we need a Supabase instance (in a Client Component):

import { createClientSupabaseClient } from '@supabase/auth-helpers-nextjs';

export default function ClientComponent() {
  const supabase = createClientSupabaseClient();
  // ...
}

This results in much cleaner code in our Client Components, since we no longer need to keep track of the client-side session manually, allowing us to essentially ditch the provider pattern (well, almost... more on that below).

Server Components

When it comes to Server Components, the pattern is pretty much the same. Except the function is named createServerComponentClient, and we also need to pass it cookies from next/headers so it can properly store the session:

import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';

export default function ServerComponent() {
  const supabase = createServerComponentClient({ cookies });
  // ...
}

The above changes means that we no longer need to have a separate module for our Supabase clients like we did before, and can instead use the provided functions directly.

Auth Provider

In the previous version of this guide, we used React Context and the Provider pattern by wrapping our application in an AuthProvider component, to ensure that a single instance of Supabase client was being used by our Client Components:

src/app/layout.js
export default async function RootLayout({ children }) {
  // ...

  return (
    <html lang="en">
      <body>
        {/*...*/}
        <AuthProvider>{children}</AuthProvider>
        {/*...*/}
      </body>
    </html>
  );
}

Thanks to the recent updates described above, that's no longer needed. But does that mean we can get rid of the AuthProvider completely? Well, not quite.

There is still a good reason to use it, and that's to trigger a router refresh whenever the current session changes. So long as we setup our pages to handle authenticated / unauthenticated state correctly, this will ensure that if a user signs out, they don't stay on the authenticated page, and similarly that they don't stay on an unauthenticated page after logging in. We'll see how to do that later on.

Handling auth state changes

To handle the above, we can listen to auth state changes inside a useEffect in the AuthProvider component, and trigger Next.js router.refresh() if the new session's access_token doesn't match the existing one:

src/components/AuthProvider.js
'use client';

import { createContext, useEffect } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { useRouter } from 'next/navigation';

export const AuthContext = createContext();

const AuthProvider = ({ accessToken, children }) => {
  const supabase = createClientComponentClient();
  const router = useRouter();

  useEffect(() => {
    const {
      data: { subscription: authListener },
    } = supabase.auth.onAuthStateChange((event, session) => {
      if (session?.access_token !== accessToken) {
        router.refresh();
      }
    });

    return () => {
      authListener?.unsubscribe();
    };
  }, [accessToken, supabase, router]);

  return children;
};

export default AuthProvider;

The accessToken is passed in from our root layout (src/app/layout.js), where we read access_token from the initial session, and pass it to the AuthProvider:

src/app/layout.js
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';

import AuthProvider from 'src/components/AuthProvider';

// ...

export default async function RootLayout({ children }) {
  const supabase = createServerComponentClient({ cookies });

  const {
    data: { session },
  } = await supabase.auth.getSession();

  return (
    <html lang="en">
      <body>
        {/*...*/}
        <AuthProvider accessToken={session?.access_token}>{children}</AuthProvider>
        {/*...*/}
      </body>
    </html>
  );
}

Now, any time the session changes or becomes invalid, our application will refresh.

As mentioned earlier, in order for this to fully work, we will also need to add logic in each page to handle the specific scenario (ie. should the user be redirected to the Home or Sign In page?).

Protected Routes and Redirecting

Authenticated Pages

For the Home and Profile pages, we want to ensure that only authenticated users have access. For this, we can check if a user exists in the current session, and redirect to the /sign-in page if not:

src/app/page.js
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';

export default async function Home() {
  const supabase = createServerComponentClient({ cookies });

  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    redirect('/sign-in');
  }

  // ...
}

Unauthenticated Pages

Similarly, we can ensure that the unauthenticated routes are only accessible if there is no current valid session:

src/app/sign-in/page.js
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';

import SignIn from 'src/components/Auth/SignIn';

export default async function SignInPage() {
  const supabase = createServerComponentClient({ cookies });
  const { data } = await supabase.auth.getSession();

  if (data?.session) {
    redirect('/');
  }

  return <SignIn />;
}

Here, we're checking for an existing session, and if one is found we redirect our user to the Home page.

Middleware and Refreshing Session

Next up, we need to ensure that the user's session does not time out or become stale, as outlined in the docs:

"When using the Supabase client on the server, you must perform extra steps to ensure the user's auth session remains active. Since the user's session is tracked in a cookie, we need to read this cookie and update it if necessary. Next.js Server Components allow you to read a cookie but not write back to it. Middleware on the other hand allow you to both read and write to cookies. Next.js Middleware runs immediately before each route is rendered. We'll use Middleware to refresh the user's session before loading Server Component routes."

To use Next.js Middleware, we'll need to create an src/middleware.js file at the root of our source folder (NOT the app, this is a common mistake!). In there we'll fetch the current session, and let Supabase client take care of updating the cookie.

Similar to Server and Client Components, there is a separate function to create Supabase client in Middleware called createMiddlewareClient. Add the following to src/middleware.js:

src/middleware.js
import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs';
import { NextResponse } from 'next/server';

export async function middleware(req) {
  const res = NextResponse.next();
  const supabase = createMiddlewareClient({ req, res });
  await supabase.auth.getSession();
  return res;
}

export const config = {
  matcher: ['/', '/profile'],
};

Note that we really only need this to run on authenticated routes, so we're using path matcher to ensure this code runs only for Home and Profile pages.

Setting up Code Exchange for PKCE flow

Supabase Auth supports two authentication flows: Implicit and PKCE.

The PKCE flow is what we're using in this guide, and is generally preferred when on the server. It introduces a few additional steps which guard against replay and URL capture attacks. Unlike the Implicit flow, it also allows users to access the access_token and refresh_token on the server.

The Next.js Auth Helpers are configured to use the PKCE auth flow as of version 0.7.0, and require us to setup a Code Exchange route in order to exchange an auth code for the user's session, which is set as a cookie for future requests made to Supabase.

NOTE For security purposes, the code has a validity of 5 minutes and can only be exchanged for an access token once. You will need to restart the authentication flow from scratch if you wish to obtain a new access token.

To setup the code exchange, let's create a Route Handler at the /auth/callback route. Create a file src/app/auth/callback/route.js and add the following:

src/app/auth/callback/route.js
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';

export async function GET(request) {
  const requestUrl = new URL(request.url);
  const code = requestUrl.searchParams.get('code');

  if (code) {
    const supabase = createRouteHandlerClient({ cookies });
    await supabase.auth.exchangeCodeForSession(code);
  }

  // URL to redirect to after sign in process completes
  return NextResponse.redirect(requestUrl.origin);
}

Now anytime a user successfully signs in, Supabase will be able to store that session, and we'll have access to it through Supabase clients.

Setting up Auth Flows

Now that we have Supabase configured, the next piece of the puzzle is to hook up our auth forms to handle the relevant authentication flows: Sign In, Sign Up, Reset Password and Update Password.

All of the forms will follow a similar pattern. We'll let Formik handle all the form-related functionality (field validation, reading the form data), and on form submit we'll create a handler function:

async function handler(formData) {
  // ...
}

<Formik onSubmit={handler} {/*...*/}>

Sign In

For Sign In, we'll use email and password credentials, and the signInWithPassword method.

Add the following to the SignIn component:

src/components/Auth/SignIn.js
const SignIn = () => {
  const supabase = createClientComponentClient();
  // ...

  async function signIn(formData) {
    const { error } = await supabase.auth.signInWithPassword({
      email: formData.email,
      password: formData.password,
    });

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

Sign Up

Similarly for Sign Up, create a handler that will call the signUp method.

Add the following in the SignUp component:

src/components/Auth/SignUp.js
const SignUp = () => {
  const supabase = createClientComponentClient();
  // ...

  async function signUp(formData) {
    const { error } = await supabase.auth.signUp({
      email: formData.email,
      password: formData.password,
    });

    if (error) {
      setErrorMsg(error.message);
    } else {
      setSuccessMsg('Success! Please check your email for further instructions.');
    }
  }
  //...
};

Reset Password

The Reset Password flow consists of 2 main steps:

  1. Allow the user to login via the password reset link
  2. Update the user's password

When the user clicks the reset link in the email they are redirected back to our application. We'll need to specify the URL for that via the redirectTo option in the resetPasswordForEmail method.

This URL will be a new route handler that will handle the authentication request from the email, and will essentially allow the user to be "logged in" in order to set a new password. That part we'll handle in the next step.

First, in our ResetPassword component, create a handler with the following:

src/components/Auth/ResetPassword.js
const ResetPassword = () => {
  const supabase = createClientComponentClient();
  // ...

  async function resetPassword(formData) {
    const { error } = await supabase.auth.resetPasswordForEmail(formData?.email, {
      redirectTo: `${window.location.origin}/auth/update-password`,
    });

    if (error) {
      // handle error
    } else {
      // handle success
    }
  }
  //...
};

Here, /auth/update-password will be the route that users will be redirected to. Note the use of window.location.origin as well, which is an easy way to ensure the base path is always matching your environment (ie. local development vs production).

Since this handler is always called on the client side (ie. in the browser), it's safe to use window this way.

Update Password

When a user gets the Reset Password email and click on the link, they'll be redirected to the /auth/update-password route in our app. This will be a route handler that will sign the user in (using the auth code from the email), and once a session is created, redirect them to the /update-password page so that they can set the new password.

The mechanism here is very much the same as what we had setup for the /auth/callback route, only this time after exchanging the code for a session, we will redirect the user to /update-password.

Create a new file under src/app/auth/update-password/route.js and add the following:

src/app/auth/update-password/route.js
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';

export async function GET(request) {
  const requestUrl = new URL(request.url);
  const code = requestUrl.searchParams.get('code');

  if (code) {
    const supabase = createRouteHandlerClient({ cookies });
    await supabase.auth.exchangeCodeForSession(code);

    return NextResponse.redirect(`${requestUrl.origin}/update-password`);
  }

  console.error('ERROR: Invalid auth code or no auth code found');

  return NextResponse.redirect(`${requestUrl.origin}/sign-in`);
}

Here, if the code exchange is successful, we're redirecting the user to /update-password page, otherwise we send them back to /sign-in.

Next up, let's update our UpdatePassword component to handle the actual password update itself. For this, we'll use updateUser method.

Add the following to src/components/Auth/UpdatePassword.js:

src/components/Auth/UpdatePassword.js
const UpdatePassword = () => {
  const supabase = createClientComponentClient();
  const router = useRouter();
  const [errorMsg, setErrorMsg] = useState(null);

  async function updatePassword(formData) {
    const { error } = await supabase.auth.updateUser({
      password: formData.password,
    });

    if (error) {
      // handle error
    } else {
      // Go to Home page
      router.replace('/');
    }
  }
  // ...
};

We also want to make sure that only authenticated users can access the /update-password route, so let's add that logic in our page code in src/app/update-password/page.js:

src/app/update-password/page.js
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';

import UpdatePassword from 'src/components/Auth/UpdatePassword';

export default async function UpdatePasswordPage() {
  const supabase = createServerComponentClient({ cookies });

  const {
    data: { session },
  } = await supabase.auth.getSession();

  if (!session) {
    redirect('/sign-in');
  }

  return <UpdatePassword />;
}

And that's it! Now our users can successfully update their password.

Wrapping Up

The complete project can be found in GitHub: github.com/mryechkin/nextjs-supabase-auth

I'm pretty happy with the patterns I've landed on here, and plan on using this setup if I'm working with Supabase for the foreseeable future (or at least until Supabase decides to change things around again 😅)

Jokes aside, this will (probably) be the last I wrote on this topic for some time. If you haven't yet, check out my previous two articles in this series for more background and the history of my explorations with Supabase:

Thanks for reading! Until next time.

00000