Dan Schlosser / writing /

Seamless Authentication with Next.js and Firebase Auth

Seamless Authentication with Next.js and Firebase Auth
Published on December 24, 2020.

When I started working on Ambrook, adopting Next.js was a clear choice. I liked that it flexibly combines statically generated (SSG) pages (like blog posts, marketing sites), with server-side rendered (SSR) pages (like our app and account pages), and merges it all together with a smart client-side navigation scheme.

Firebase Authentication has been my go-to authentication choice for years. It handles all of the complexities of managing multiple authentication methods (password, Facebook, Google, phone number, magic link) into a single account system.

In using both, I hoped to create a best-in-class user experience. Here's what I wanted:

  • Server-side rendered private pages that load the user's data without lots of spinners.
  • Statically generated public pages, that show the same content for all users.
  • Anonymous authentication that lets users start to input their information and use our app before being asked to make an account.
  • Server-side redirects for users that load a private page without being logged in.
  • Client-side redirects for users that click an onsite link to a private page without being logged in.

Furthermore, I had some developer experience requirements:

  • Make it easy to introduce new pages and API routes that are auth-guarded.
  • Reduce the likelihood that private pages are accidentally exposed.

While the basic integration was straightforward, the combination of these two tools created a fair number of cases to consider. Next.js lets users access pages by client side navigation and server side navigation. Firebase Authentication issues ID tokens that only last for an hour, after which they need to be refreshed using the client-side SDKs.

Authentication and SSR

Authenticating pages accessed via client side navigation is easy – the Firebase SDK will log users in automatically if they have an account, and private pages can check the currentUser. But for the initial HTTP request, we'll need to use a cookie to store the user's ID token to be verified on the server. Here's how it works:

  1. We sign in the user using the Javascript SDK:

    const { user } = await auth().signInWithCredential(cred);
  2. On the client, we generate an ID token and write it to the user's cookies.

    import cookie from 'js-cookie';
    
    export const persistUserCredential = (user: firebase.User) => {
      const token = await user.getIdToken();
      cookie.set('token', token, {
        expires: 1,
        path: '/',
      });
    }
  3. When the user next requests an SSR-powered page, the cookies are passed along in the request headers. We can then verify the ID token to validate a user's identity.

    // Some code is missing here, I'll go into more detail later.
    const token = getCookie('token', ctx.req.headers);
    const { uid, email } = await verifyIdToken(token);

Anonymous Authentication

We want users to be able to start using our app without making an account, and connect their anonymous profile to a full account later if they wish. To do this, we're using Firebase's Anonymous Authentication. To integrate this into Next.js, we modify our custom _app.tsx. Here's how it works:

  1. Every time a page renders on the client, our _app.tsx's render function will run. We use the useEffect hook to run our setup code on the first render only.
  2. We set up a listener to changes in the Firebase Authentication state using firebase.auth.onAuthStateChanged, which takes a callback that contains the current user. It runs first with null, and then once Firebase has loaded the logged in user from the local session.
  3. We either persist the user's credentials to the cookie, if they're logged in, or log them in anonymously if they are not.
// pages/_app.tsx
import { AppProps } from 'next/app';
import React, { FC, useEffect } from 'react';
import firebase from 'firebase/app';
import persistUserCredential from 'utils/auth/persistUserCredential';

const MyApp: FC<AppProps> = ({ Component, pageProps }: AppProps): JSX.Element => {

  // ...

  useEffect(() => {
    return firebase.auth().onAuthStateChanged((user) => {
      if (user) {
        // If the user just signed in, we call the code to create the cookie.
        persistUserCredential(user);
      } else {
        firebase.auth()
          .signInAnonymously()
          .catch(function (error) {
            // Handle Errors here.
          });
      }
    });
  }, []);

  // ...

  return (<Component {...pageProps} />);
};

export default MyApp;

Next, we'll set up the code that verifies token on the server.

Handling redirects

It's important that users get redirected to the login page if they're logged out but trying to access a private resource, no matter if they navigate on the client or directly to a private URL. I use the redirect feature introduced in Next.js 10, which allows GetServerSideProps to return a redirect object that will be executed on the client or server, depending on the user's context.

I wrote a wrapper function, withPrivateServerSideProps that wraps a page's GetServerSideProps function:

// withPrivateServerSideProps.ts
import { GetServerSideProps } from 'next';
import isAuthenticated from 'utils/auth/isAuthenticated';

/**
 * This function wraps a page's GetServerSideProps function. It passes the
 * `redirect` object if the user needs to authenticate, and calls the wrapped
 * function otherwise.
 */
export default function withPrivateServerSideProps<P>(
  getServerSidePropsFunc?: GetServerSideProps,
): GetServerSideProps {
  const withPrivateSSP: GetServerSideProps = async (ctx) => {
    const _isAuthenticated = await isAuthenticated(ctx);

    // If not authenticated, we return a redirect object that instructs
    // Next.js to redirect to our login page.
    if (!_isAuthenticated) {
      return {
        redirect: {
          destination: `/login?redirectTo=${ctx.resolvedUrl}`,
          permanent: false,
        },
      };
    }

    if (getServerSidePropsFunc) {
      return await getServerSidePropsFunc(ctx);
    }
    return { props: {} };
  };

  return withPrivateSSP;
}

This code uses a helper function, isAuthenticated, to determine if the user is authenticated. It uses the Firebase Admin SDK's verifyIdToken function to validate the token and look up the user's basic details.

// utils/auth/isAuthenticated.ts
import { GetServerSidePropsContext } from 'next';
import { getCookie } from 'utils/auth/cookies';

export default async function isAuthenticated(
  ctx: GetServerSidePropsContext
): Promise<boolean> {
  const token = getCookie('token', ctx.req.headers);

  if (token) {
    const { uid, email } = await verifyIdToken(token);
    // An anonymous user may have a UID, but authenticated users must have an
    // account (an email address).
    return !!email;
  }

  return false;
}

Here, the getCookie function is parsing the cookie header, and either returning the token key or undefined.

This makes for a very simple integration into a private page, like /account:

// pages/account.tsx
import withPrivateServerSideProps from 'hocs/withPrivateServerSideProps';
import React from 'react';
import { getAccountDetails } from 'lib/account'
import Account, { AccountProps } from 'screens/Account/Account';

const AccountPage = (props: AccountProps): JSX.Element => {
  return (
    <Account {...props} />
  );
};

export const getServerSideProps = withPrivateServerSideProps(
  async (ctx) => {
    try {
      const accountDetails = await getAccountDetails();
      return { props: accountDetails };
    } catch (error) {
      return { props: {} };
    }
  },
);

export default AccountPage;

Handling expired tokens

Firebase's ID tokens expire after about an hour, so it's likely that users that request a page will need to refresh their token in order to be authenticated. If this is the case, we redirect to the /login page, just like if they were logged out. However, in this case, we detect the presence of an existing token and attempt to refresh it. Here, we use firebase.auth().onIdTokenChanged, which detects not just changes in the user's logged in status, but also when their ID token is refreshed automatically by Firebase. On the login page, we detect this state, persist their new ID token to the cookie, and redirect them to where they where going.

// pages/login.tsx
import firebase from 'firebase/app';
import { useRouter } from 'next/router';
import persistUserCredential from 'utils/auth/persistUserCredential';
import { useEffect } from 'react'

const LoginPage = ({
  redirectUrl
}: {redirectUrl: string}): JSX.Element => {
  const router = useRouter();

  // ...

  useEffect(() => {
    return firebase.auth().onIdTokenChanged((user) => {
      if (user && !user.isAnonymous) {
        persistUserCredential(user).then(() => {
          router.push(redirectUrl);
        });
      }
    });
  }, [redirectUrl, router]);

  return (
    /* login page content */
  )
};

export default LoginPage;

Lots of cases to handle!

Between client-side and server side navigation, private and public pages, and the user's authentication state (logged out, logged in, expired token), there are a lot of cases to handle. But by carefully managing and correctly passing around the ID token, we can allow Next.js to authenticate a user from their ID token in whatever context we find them.

There were a couple of features we added that I didn't include here, but they all follow from the structure outlined above. Asynchronous API requests using fetch() do not pass cookies by default, so I had to write a wrapper that includes the ID token in requests to my API routes. Also, we ended up building out "account-enhanced" pages that load basic content for logged out users and more content for logged in users. These pages follow the more traditional SPA model: Load the page and show a spinner, request the authentication state asynchronously, and then load the logged-in content (or not). We also use Redux in our app, but I removed that code from the samples above for clarity.

Hopefully this guide is helpful for your next project! If you have any questions, feel free to reach out to me on Twitter.