Firebase Authentication with NextJS (SSR)
This document provides an overview of the authentication flow and session management in a Next.js application using Firebase.
Hi there! That’s me.
I recently moved from React to Next.js for better performance and SEO. Along the way, I needed to implement an authentication mechanism in Next.js, using Firebase since I have users already using Firebase Auth in production.
However, most resources online focus on Clerk / NextAuth. The few tutorials that I found that cover Firebase are filled with bad practices & security vulnerabilities such as this video or this one. So, here’s my guide—it’s not perfect, but it’s a start. I’d love to hear your thoughts!
Just do normal client authentication?
For most client-side SPAs, Firebase Client SDK's built-in authentication mechanisms (e.g., onAuthStateChanged
) are sufficient. Add to that React’s context, and you are set to go. With NextJS, however, server-side components can’t access client-side properties like context. So even if you set up context correctly, every request that needs auth status would have to come from the client. This makes it impossible to access the auth state within server components directly.
TLDR
In short, we leverage the async function cookies() from 'next/headers'
that allows you to read the HTTP incoming request cookies in Server Component, and read/write outgoing request cookies in Server Actions or Route Handlers.
Flow: Client sends Firebase ID token → Server creates/verifies session cookie → Middleware checks cookie → Sessions auto-refresh.
I recommend reading these two docs
Table of content
Initializations: Firebase Client & Admin SDK.
Client Login: Create a user, then an ID token, then send the token to the server.
Server Creates a Session: Catch the ID token, and use it to create a session cookie. Then set the cookie in cookies.
Client Refresh: Force refresh the token on the client and send it to the server.
Server Refresh: Catch a new ID token, create a new session cookie, and set it in cookies.
Middleware: Add a “stupid“ middleware layer to navigate users based on cookie availability.
Logout: The client sends the command, and we revoke all sessions and delete the cookie.
1. Initilzaions
Initialize Firebase Client SDK
import { initializeApp } from "firebase/app";
import { getStorage } from "firebase/storage";
import { getFunctions } from "firebase/functions";
import { getFirestore } from "firebase/firestore";
import { getAuth } from "firebase/auth";
let app;
const getFirebaseApp = () => {
if (!app) {
app = initializeApp({
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY_CLIENT,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN_CLIENT,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID_CLIENT,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET_CLIENT,
messagingSenderId:
process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID_CLIENT,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID_CLIENT,
});
}
return app;
};
// Initialize Firebase services
const FIREBASE_APP_CLIENT = getFirebaseApp();
export const FIREBASE_STORAGE_CLIENT = getStorage(FIREBASE_APP_CLIENT);
export const FIREBASE_AUTH_CLIENT = getAuth(FIREBASE_APP_CLIENT);
export const FIREBASE_FIRESTORE_CLIENT = getFirestore(FIREBASE_APP_CLIENT);
export const FIREBASE_FUNCTIONS_CLIENT = getFunctions(FIREBASE_APP_CLIENT);
Initialize Firebase Admin (server) SDK
import admin from "firebase-admin";
// Ensure Firebase Admin is initialized only once
if (!admin.apps.length) {
const serviceAccount = process.env.FIREBASE_ADMIN_SDK
? JSON.parse(process.env.FIREBASE_ADMIN_SDK)
: null;
if (serviceAccount) {
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, // Add your storage bucket
});
} else {
console.error("Firebase Admin SDK credentials are missing.");
throw new Error(
"Firebase Admin initialization failed: Missing credentials."
);
}
}
const FIREBASE_FIRESTORE = admin.firestore();
const FIREBASE_AUTH = admin.auth();
const FIREBASE_ADMIN = admin
export { FIREBASE_ADMIN, FIREBASE_FIRESTORE, FIREBASE_AUTH };
2. Client Login
User signup from a client component using the Firebase Client SDK.
The user generates an ID token using the Firebase Client SDK.
We send the ID token to our server via an HTTPS server action.
Important to know
Server actions create a public endpoint—treat all incoming data as hostile and always validate on the server. Use the server-only library to avoid potential breaches.
HTTPS communication
Vercel: Out of the box, every Deployment on Vercel is served over an HTTPS connection.
AWS Amplify: Amplify manages SSL/TLS certificates on your behalf to securely serve traffic to your domain over HTTPS, no matter if your app has 100 or 100,000 users.)
LoginPage.js
"use client"
const handleLogin = async (e) => {
e.preventDefault();
try {
const userCredential = await signInWithEmailAndPassword(
FIREBASE_AUTH_CLIENT,
email,
password
);
const Idtoken = await userCredential.user.getIdToken();
await createSession(Idtoken);
router.push("/");
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
3. Server Creates a Session
Server catches the ID token sent from client.
Server verifies the ID token via Firebase Admin SDK.
Server creates a session cookie using the ID token.
Server stores the session cookie securely in the browser cookies as an HttpOnly cookie, inaccessible to JavaScript.
Every request validates the session cookie to ensure the user remains authenticated.
Server actions
lib/session.js
"use server";
import { redirect } from "next/navigation";
import { FIREBASE_AUTH } from "../firebase";
import { cookies } from "next/headers";
Verify the token, create a 5-day session cookie, and set it in cookies.
lib/session.js
export async function createSession(idToken) {
if (!idToken) return null;
try {
const decodedToken = await FIREBASE_AUTH.verifyIdToken(idToken);
if (!decodedToken) return { success: false, error: "Invalid token." };
const expiresIn = 60 * 60 * 24 * 5 * 1000; // 5 days
const sessionCookie = await FIREBASE_AUTH.createSessionCookie(idToken, { expiresIn });
await setCookie("firebaseToken", sessionCookie, { maxAge: expiresIn / 1000 });
console.log("Session created for UID:", decodedToken.uid);
return { success: true, uid: decodedToken.uid };
} catch (error) {
console.error("Error creating session:", error.message);
return { success: false, error: error.message };
}
}
Set, get, or delete a cookie
Ensure secure
, HttpOnly
, and SameSite
attributes are used correctly in production.
lib/session.js
export async function setCookie(key, value, options = {}) {
const cookieStore = await cookies();
cookieStore.set(key, value, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24 * 5, // Default: 5 days
...options,
});
}
// Get a cookie value
export async function getCookie(key) {
const cookieStore = await cookies();
const cookie = cookieStore.get(key);
return cookie ? cookie.value : null;
}
// Delete a cookie
export async function deleteCookie(key) {
const cookieStore = await cookies();
cookieStore.delete(key);
}
We can now validate & authorize requests from our server.
lib/session.js
"use server"
import { cookies } from "next/headers";
import { FIREBASE_AUTH, FIREBASE_FIRESTORE } from "../firebase";
export async function getUserData() {
try {
const idToken = await getCookie("firebaseToken")
if (!idToken) return null;
const decodedToken = await FIREBASE_AUTH.verifySessionCookie(idToken);
if (!decodedToken?.uid) return null;
const userSnapshot = await FIREBASE_FIRESTORE.collection("users").doc(decodedToken.uid).get();
if (!userSnapshot.exists) return null;
const data = userSnapshot.data();
const serializedData = Object.fromEntries(
Object.entries(data).map(([key, value]) => [key, value?.toDate?.()?.toISOString() || value])
);
return { id: userSnapshot.id, ...serializedData };
} catch (error) {
console.error("Error fetching user data:", error.message);
return null;
}
}
4. Client Refresh
I created a dummy component <AuthComp/>
that I can use to force refresh the ID token on the client and send it to the server. The getIdToken()
method takes two parameters: the user and a boolean forceRefresh?. Set the boolean to true
force a refresh. Make sure this AuthComp is being rendered.
components/AuthComp.js
"use client";
import { useEffect } from "react";
import { getIdToken, signOut, onAuthStateChanged } from "firebase/auth";
import { createSession } from "../lib/session";
import { FIREBASE_AUTH_CLIENT } from "../lib/firebase/client";
export default function AuthComp() {
useEffect(() => {
const refresh = async (user) => {
try {
if (!user) {
console.log("No user logged in. Skipping session refresh.");
return;
}
const idToken = await getIdToken(user, true);
const response = await createSession(idToken);
if (!response.success) {
console.warn("Session refresh failed. Redirecting to login.");
}
} catch (error) {
await signOut(FIREBASE_AUTH_CLIENT);
console.log("Error refreshing session (USEEFFECT):", error);
}
};
const unsubscribe = onAuthStateChanged(FIREBASE_AUTH_CLIENT, (user) => {
refresh(user);
});
return () => unsubscribe(); // Cleanup the listener
}, []);
return <></>;
}
5. Server Refresh
Refreshes the session by verifying the ID token and creating a new 5-day session cookie (we use createSession()
again).
6. Middleware (“stupid check“)
Next.js middleware works only with APIs that the Edge runtime supports. The firebase-admin
library relies on Node.js features like the crypto
module and file system APIs, which the Edge runtime doesn’t have. So we can’t perform real validation inside our middleware such as:
FIREBASE_AUTH.verifySessionCookie()
So this makes (in our case) middleware-based verification unsuitable for deep checks. For us, middleware should just check if cookies exist and handle redirects, while the actual verification should happen on the server side.
// middleware.js
import { NextResponse } from "next/server";
export function middleware(request) {
const isAuthenticated = request.cookies.get("firebaseToken");
const pathname = request.nextUrl.pathname;
const isAuthPage = ["/login", "/signup"].some((path) =>
pathname.startsWith(path)
);
const isProtectedPage = pathname.startsWith("/dashboard");
if (isAuthenticated) {
// Redirect authenticated users away from auth pages
if (isAuthPage || pathname === "/") {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
// Allow access to dashboard or other pages (if required)
return NextResponse.next();
}
// Redirect unauthenticated users trying to access protected pages
if (isProtectedPage) {
return NextResponse.redirect(new URL("/login", request.url));
}
// Allow unauthenticated users to access public pages, including "/"
return NextResponse.next();
}
An interesting hack for running the Firebase Admin SDK on the Edge runtime is this library—give it a shot if you'd like.
7. Server Logout
Logs out the user by revoking their Firebase session and deleting the session cookie, then redirects to the home or login page.
lib/session.js
export async function logoutSession() {
try {
const cookieStore = await cookies();
const sessionCookie = cookieStore.get("firebaseToken")?.value;
if (sessionCookie) {
// Revoke all Firebase sessions
await revokeAllSessions(sessionCookie);
// Clear the cookie by setting it to an empty value and past date
cookieStore.delete("firebaseToken")
// Redirect the user
redirect("/"); // Redirect to login or home page
}
} catch (error) {
console.error("Error during logout:", error.message);
}
}
export async function revokeAllSessions(sessionCookie) {
const decodedIdToken = await FIREBASE_AUTH.verifySessionCookie(sessionCookie);
await FIREBASE_AUTH.revokeRefreshTokens(decodedIdToken.sub);
}
Notes
CSR vs. SSR vs. ISR: Firebase's client-side authentication works well for CSR, but with SSR or ISR in Next.js, server-side access to authentication requires session cookies since client-side context is inaccessible.
Security Best Practices: Use secure cookie attributes (
HttpOnly
,Secure
,SameSite
) to protect session cookies from XSS and CSRF attacks, ensuring they are only accessible by the server.Middleware: Middleware is useful for checking cookie presence and redirecting unauthenticated users but should delegate deep validations to the server for security and reliability.
Summary
Secure Authentication: The client communicates with the server using Firebase ID tokens, which are converted into secure session cookies.
Server-Side Validation: Every request validates the session cookie to ensure the user remains authenticated.
Refresh Mechanism: Sessions are refreshed periodically to maintain long-lived user sessions securely.
Logout: Sessions are invalidated by clearing the cookie, ensuring the user is logged out securely.
where should I use AuthComp? in the layout?
Is there any way to setup an axios interceptor that uses the id token and runs on the server and the client? Is it possible to refresh the token on the server before it is expired?