Next.js Firebase Auth using Cookies

Thomas Millar / January 6, 2021

7 min read

I've been using Next.js for a year now. I love it. But to this day I still struggle when I want the full power of serverless, and have a reasonable authentication strategy. A few weeks back I finally dug in and figured it out. This post i'll go into the details of the best setup for Firebase authentication with Next.js.

tl;dr If you want the full flexibility of Next.js,– SSR, SSG, or CSR – and authentication and serverless. The only way is to use cookies.

Remember cookies?

Cookies, you know that thing the web invented in the 90's to solve authentication on the web? The reason why authentication is so difficult in the JAM Stack universe is kind of a function of what made JAM Stack so great. Serverless.

Hear me out. See, when JAMStack started becoming a thing, the entire point was that you don't really have to build a backend. You offload your work to public APIs or some small handcrafted lambda functions. The issue is that it felt wrong to put an entire rails backend into a custom lambda function, and the existing Auth APIs like Firebase and Auth0 were designed for mobile apps.

As a result, all the public tutorials on authentication pushed you to emulate a mobile app authentication strategy (JWTs in memory) for your Next.js sites. The vendors did it because their marketing managers were trying to push the technology that they already had. New people to the community pushed it because it technically worked. However, I believe it set the entire community back a year.

Cookies are great because the browser automatically sends it with each request to your origin. Therefore if you want to do Client Side Rendering with authenticated API endpoints. No problem. If you want to do Server Side Rendering where unauthenticated users get a redirect. No problem. If you want to Statically Generate your site with authenticated server side props. No problem. If you want to go old school and just have an authenticated HTML form that post-backs on submission. No problem.

JWTs in local storage suck because they're hard to revoke, they require custom headers, bloating your client side with unnecessary javascript to add auth to requests, and they're not available on the server by default.

The solution to authentication on the web is cookies. It always has been.

Firebase Authentication

Firebase is great. It's basically free. It has tons of oAuth providers, analytics, and databases. However all the default Next.js Firebase tutorials push you towards the local storage, JWT, Firebase Authentication strategy. What people don't realize is that Firebase Auth allows you to create session tokens that you can safely store as a cookie. It's just hidden deeper in the docs.

Okay first, let's go over how this works.

The main idea is that during login, the client browser will redirect to a Firebase flow which will handle the authentication. Firebase will then redirect you to a "Login with Twitter" page, or email / password prompt. Then the user successfully authenticates, the login provide will redirect the clients browser back to Firebase, then Firebase will redirect back to you with an ID token.

Normally, that is the end of the process. The Firebase library will store that token in local storage and you can use it to authenticate any requests and validate the ID token with Firebase server side in any of your endpoints.

Don't stop here. There's one more step to unlock cookie based authentication.

Creating the Cookie

Once your frontend receives the ID Token, you'll want to send it a Next.js API Route to create a session cookie using the FirebaseAdmin SDK, that route should set it as a cookie.

The frontend should look something like,

// pages/index.js
import firebase from "lib/firebase";

async function login() {
  let result = await firebase
    .auth()
    .signInWithPopup(new firebase.auth.TwitterAuthProvider());

  let resp = await fetch("/api/session", {
    method: "POST",
    headers: {
      token: await result.user.getIdToken(),
    },
  });

	window.location = "/dashboard";
}

export default function Page() {
	return (
		<div>
			<button onClick={login}>Log In</button>
		</div>
	)
}

The API route should look something like,

// pages/api/session.js
import firebaseAdmin from "lib/firebaseadmin";

async function handler(req, res) {
  const { method } = req;
  const expiresIn = 60 * 60 * 24 * 5 * 1000; // 5 days in milliseconds;

  switch (method) {
    case "POST":
      const { token } = req.headers;
      if (token) {
        return firebaseAdmin
          .auth()
          .createSessionCookie(token, { expiresIn })
          .then(
            (sessionCookie) => {
              // Set cookie policy for session cookie.
              res.setHeader("Access-Control-Expose-Headers", "Set-Cookie");
              res.setHeader(
                "Set-Cookie",
                `session=${sessionCookie};Path=/;HttpOnly;Max-Age=${expiresIn};`
              );
              res.status(200).json(user);
            },
            (error) => {
              res.status(401).json({ error: "Unauthorized Request" });
            }
          );
      } else {
        return res.status(400).json({ error: "Invalid input" });
      }
    default:
      res.setHeader("Allow", ["POST"]);
      res.status(405).json({ error: `Method ${method} Not Allowed` });
      return;
  }
}

export default handler;

Now, you should be able to verify that a cookie called session exists in your browser,

notion image

Validating the cookie (CSR + API Route)

Now that your client is logged in, let's verify this using every technique Next.js has its disposal. First, let's do a CSR page.

// pages/dashboard.js
import useSWR from "swr";

const fetcher = (...x) => fetch(...x).then(y => y.ok && y.json())

export default function Page() {
  const [user] = useSWR("api/user");

  return (
		<div>
      { user ? (
         <p>User is logged in: {JSON.stringify(user)}</p>
      ) : (
         <p>User is not logged in</p>
      )}
    </div>
  )
}

When the CSR page loads it'll make a GET request to the api/user endpoint to get the user information extracted from the validated session token. To do that, we'll need to create a user endpoint.

// pages/api/user.js
import firebaseAdmin from "lib/firebaseadmin";

async function handler(req, res) {
  const { method } = req;

  switch (method) {
    case "GET":
	    const user = await firebaseAdmin.auth().verifySessionCookie(req.cookies.session);
      res.status(200).json({ user })
    default:
      res.setHeader("Allow", ["GET"]);
      res.status(405).json({ error: `Method ${method} Not Allowed` });
      return;
  }
}

export default handler;

Validating Cookie (SSR)

For SSR, we'll want to do something similar but all in the getServerSideProps . We will try to validate the session cookie with Firebase, if we cannot validate the user, we will redirect back to the login page instead rendering the page.

// pages/api/ssr.js
import firebaseAdmin from "lib/firebaseAdmin"

export default function Page({ user }) {
	return (
		<div>
      { user ? (
         <p>User is logged in: {JSON.stringify(user)}</p>
      ) : (
         <p>User is not logged in</p>
      )}
    </div>
  )
}

export async function getServerSideProps({ req, res }) {
	const user = await firebaseAdmin.auth().verifySessionCookie(req.cookies.session);

	if (!user) {
    return {
      redirect: {
        destination: "/login",
        permanent: false,
      },
    }
  }

  return {
    props: { user },
  }
}

Logging Out

You'll always want your users to be able to log out. This is a pretty simple operation. There are a few ways to do it. Personally, I like the old school technique of redirecting them to a /logout endpoint which will clear the session cookie with a header.

// pages/api/logout.js

async function handler(req, res) {
	res.setHeader(
		"Set-Cookie",
		"session=deleted;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT"
	);
	res.setHeader("Location", "/");
	res.status(302).end();
}

export default handler;
 

There you have it. Cookie based authentication with Firebase as our Auth provider. Firebase is basically free for authentication. The cookie based method of verification doesn't require any additional network requests, plus you retain full flexibility with Next.js by being able to authenticate with CSR and SSR while still deploying completely serverless.

Enjoy!