Logo
Published on

Next.js 14: Server-side Authentication using Cookies with Firebase Admin SDK

Authors
  • Name
    Twitter

In this article, I will explain how to handle server-side authentication using Firebase Auth in Next.js 14, taking advantage of React server components.

One of the key benefits of server-side authentication is that it allows you to securely authenticate users and protect sensitive data on the server side. With Next.js 14 and Firebase Auth, you can easily implement cookie-based authentication, which provides a secure way to manage user sessions and access control.

In Next.js 14, you can take advantage of React server components to handle server-side authentication seamlessly. React server components allow you to run code on the server side during rendering, making it possible to authenticate users and protect sensitive data before it is sent to the client.

Repo Link: https://github.com/bilgehan-biricik/nextjs-firebase-auth-demo

Setup

To get started first we create a Next.js app:

npx create-next-app@latest

Then we install Firebase Client and Admin SDK’s:

npm i firebase firebase-admin

Now, we are ready to implementation.

Implementation

For our test scenario we will not allow unauthorized access to /dashboard page and redirect the request to /sign-in page. If user signs in successfully then will be redirect to /dashboard page.

First, we will create a helper module that handle all the Firebase connections, getting initialized apps and getting session cookies for admin and client side:

// src/lib/firebase/firebase-admin.ts

import "server-only";

import { cookies } from "next/headers";

import { initializeApp, getApps, cert } from "firebase-admin/app";
import { SessionCookieOptions, getAuth } from "firebase-admin/auth";

export const firebaseApp =
  getApps().find((it) => it.name === "firebase-admin-app") ||
  initializeApp(
    {
      credential: cert(process.env.FIREBASE_ADMIN_SERVICE_ACCOUNT),
    },
    "firebase-admin-app"
  );
export const auth = getAuth(firebaseApp);

export async function isUserAuthenticated(
  session: string | undefined = undefined
) {
  const _session = session ?? (await getSession());
  if (!_session) return false;

  try {
    const isRevoked = !(await auth.verifySessionCookie(_session, true));
    return !isRevoked;
  } catch (error) {
    console.log(error);
    return false;
  }
}

export async function getCurrentUser() {
  const session = await getSession();

  if (!(await isUserAuthenticated(session))) {
    return null;
  }

  const decodedIdToken = await auth.verifySessionCookie(session!);
  const currentUser = await auth.getUser(decodedIdToken.uid);

  return currentUser;
}

async function getSession() {
  try {
    return cookies().get("__session")?.value;
  } catch (error) {
    return undefined;
  }
}

export async function createSessionCookie(
  idToken: string,
  sessionCookieOptions: SessionCookieOptions
) {
  return auth.createSessionCookie(idToken, sessionCookieOptions);
}

export async function revokeAllSessions(session: string) {
  const decodedIdToken = await auth.verifySessionCookie(session);

  return await auth.revokeRefreshTokens(decodedIdToken.sub);
}

isUserAuthenticated function checks whether a user is authenticated or not by controlling the session cookie. To access cookies in Next.js there is new built-in function called cookies() . This function is only accessible inside server components, server actions or route handlers.

getCurrentUser function gets current user’s information if user is authenticated.

createSessionCookie function is a wrapper function on top of the Firebase admin’s auth function.

revokeAllSessions function revokes all sessions.

We need a 2 endpoints that will handle sign in and sing out. For that we can create 2 route handlers in Next.js.

In sign in route handler, we only need idToken that generated by Firebase. Then we can use createSessionCookie function to create session string by passing the idToken. Once we get the session string then we can create a secure session cookie by using Next.js’s cookies() function.

// src/app/api/auth/sign-in/route.ts

import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";

import { APIResponse } from "@/types";
import { createSessionCookie } from "@/lib/firebase/firebase-admin";

export async function POST(request: NextRequest) {
  const reqBody = (await request.json()) as { idToken: string };
  const idToken = reqBody.idToken;

  const expiresIn = 60 * 60 * 24 * 5 * 1000; // 5 days

  const sessionCookie = await createSessionCookie(idToken, { expiresIn });

  cookies().set("__session", sessionCookie, {
    maxAge: expiresIn,
    httpOnly: true,
    secure: true,
  });

  return NextResponse.json<APIResponse<string>>({
    success: true,
    data: "Signed in successfully.",
  });
}

In sing out route handler, all we have to is that delete the session cookie and revoke session by calling revokeAllSessions function.

// src/app/api/auth/sign-out/route.ts

import { NextResponse } from "next/server";
import { cookies } from "next/headers";

import { APIResponse } from "@/types";
import { revokeAllSessions } from "@/lib/firebase/firebase-admin";

export async function GET() {
  const sessionCookie = cookies().get("__session")?.value;

  if (!sessionCookie)
    return NextResponse.json<APIResponse<string>>(
      { success: false, error: "Session not found." },
      { status: 400 }
    );

  cookies().delete("__session");

  await revokeAllSessions(sessionCookie);

  return NextResponse.json<APIResponse<string>>({
    success: true,
    data: "Signed out successfully.",
  });
}

We will be need one last module that handles client-side authentication. Basically this module will be responsible for calling Firebase’s client-side sign in and sign out methods. Also the route handlers that we created will be called and handled the responses in here.

// src/lib/firebase/auth.ts

import { GoogleAuthProvider, signInWithPopup } from "firebase/auth";

import { APIResponse } from "@/types";
import { auth } from "./firebase";

export async function signInWithGoogle() {
  const provider = new GoogleAuthProvider();

  try {
    const userCreds = await signInWithPopup(auth, provider);
    const idToken = await userCreds.user.getIdToken();

    const response = await fetch("/api/auth/sign-in", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ idToken }),
    });
    const resBody = (await response.json()) as unknown as APIResponse<string>;
    if (response.ok && resBody.success) {
      return true;
    } else return false;
  } catch (error) {
    console.error("Error signing in with Google", error);
    return false;
  }
}

export async function signOut() {
  try {
    await auth.signOut();

    const response = await fetch("/api/auth/sign-out", {
      headers: {
        "Content-Type": "application/json",
      },
    });
    const resBody = (await response.json()) as unknown as APIResponse<string>;
    if (response.ok && resBody.success) {
      return true;
    } else return false;
  } catch (error) {
    console.error("Error signing out with Google", error);
    return false;
  }
}

Now, we can create our pages and components.

For the sake of simplicity of this example, I will be creating a client component for sign in and dashboard pages that contains all contents for both pages. Normally, you shouldn’t do that and separate the logic to different components.

This component will be rendered differently variant prop. If we pass sign-in it will render sing in page’s content. If we pass dashboard it will render dashboard page’s content.

// src/app/_components/PageContent.tsx

"use client";

import { useRouter } from "next/navigation";

import { UserRecord } from "firebase-admin/auth";

import { signInWithGoogle, signOut } from "@/lib/firebase/auth";

export default function PageContent({
  variant,
  currentUser,
}: {
  variant: "sign-in" | "dashboard";
  currentUser?: UserRecord;
}) {
  const router = useRouter();

  const handleSignIn = async () => {
    const isOk = await signInWithGoogle();

    if (isOk) router.push("/dashboard");
  };

  const handleSignOut = async () => {
    const isOk = await signOut();

    if (isOk) router.push("/sign-in");
  };

  const buttonStyle = "bg-slate-500 mt-2 px-2 py-1 rounded-md text-slate-50";

  if (variant === "sign-in")
    return (
      <>
        <h1>Sing In Page</h1>
        <button className={buttonStyle} onClick={handleSignIn}>
          Sign In with Google
        </button>
      </>
    );
  else if (variant === "dashboard")
    return (
      <>
        <h1>Dashboard Page</h1>
        <p>Welcome, {currentUser?.displayName}</p>
        <button className={buttonStyle} onClick={handleSignOut}>
          Sign Out
        </button>
      </>
    );
  else return null;
}

For sing in page, I will be create a server component under app directory. In this page, we will check if there is a signed in user in the app. If there is a signed in user we will be redirect the user to dashboard page. Because there is not point of showing sing in page to a signed in user.

// src/app/sign-in/page.tsx

import { redirect } from "next/navigation";

import { isUserAuthenticated } from "@/lib/firebase/firebase-admin";
import PageContent from "../_components/PageContent";

export default async function SignInPage() {
  if (await isUserAuthenticated()) redirect("/dashboard");

  return (
    <main className="container">
      <PageContent variant="sign-in" />
    </main>
  );
}

For dashboard page, this is also will be a server component. In this page we will get current users information. If there is not, redirect user to sing in page. If there is, we will pass it as prop to PageContent component. But there is a catch in here. Since we want to pass a prop to client component from server component, it must be serializable. Firebase returns user info as UserRecord class. This can’t be serializable by default but the class provides a function called toJSON() to convert class to JSON-serializable object. Thus, we can access user info from our client component and print user’s display name on the screen.

// src/app/dashboard/page.tsx

import { redirect } from "next/navigation";

import { getCurrentUser } from "@/lib/firebase/firebase-admin";
import PageContent from "../_components/PageContent";

export default async function DashboardPage() {
  const currentUser = await getCurrentUser();
  if (!currentUser) redirect("/sign-in");

  return (
    <main className="container">
      <PageContent
        variant="dashboard"
        currentUser={currentUser.toJSON() as typeof currentUser}
      />
    </main>
  );
}

Conclusion

Now, we learned bases of how to implement server-side authentication using Firebase Auth in Next.js 14. You can also modify this implementation by your needs. Like adding middleware, so that you can check is user is authenticated in every request.