Server Components (RSC) cannot rely on the client’s in-memory Firebase state. The reliable pattern is to sign in on the client with the Firebase Web SDK, exchange the ID token for a session cookie on the server, and read that cookie in RSC. Firebase officially supports minting and verifying session cookies with the Admin SDK, with configurable lifetimes from 5 min to 14 days.
Implications. Treat the session cookie as your authoritative server credential in RSC and Route Handlers. Keep any Admin-SDK work in the Node.js runtime, not Edge, because the Admin SDK depends on Node modules unavailable in Edge.
Minimal flow
- Client signs in with the Firebase Web SDK and obtains an ID token.
- Client posts the ID token to a server endpoint you control.
- Server calls
createSessionCookie(idToken, { expiresIn })
, sets an HttpOnly, Secure cookie, and returns. - RSC and Route Handlers read and verify the cookie with
verifySessionCookie
.
Notes for 2025. Next.js 15 is stable, Server Actions and next/headers
cookies()
support setting cookies in Actions and Route Handlers. Prefer Node.js runtime for any Admin SDK usage. Server Actions are public endpoints; validate input and origin just like an API route.
Client sign-in (extract)
Authenticate on the client, then exchange the ID token. Keep the ID token out of URLs.
// app/(auth)/login/actions.ts
"use client";
import { signInWithEmailAndPassword } from "firebase/auth";
import { auth } from "@/externals/firebase/client";
export async function login(email: string, password: string) {
const { user } = await signInWithEmailAndPassword(auth, email, password);
const idToken = await user.getIdToken(/* forceRefresh? false */);
// Exchange via POST; do not put tokens in query strings.
const res = await fetch("/api/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ idToken }),
credentials: "include",
});
if (!res.ok) throw new Error("Session exchange failed");
}
Session exchange (Route Handler, Node.js runtime)
Route Handlers make CSRF and origin checks straightforward and keep Admin SDK off the Edge runtime.
// app/api/session/route.ts
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { adminAuth } from "@/externals/firebase/server";
const Body = z.object({ idToken: z.string().min(20) });
export async function POST(req: Request) {
// Basic CSRF / origin gate
const origin = req.headers.get("origin") ?? "";
if (!origin || !new URL(origin).host.endsWith(process.env.NEXT_PUBLIC_ALLOWED_HOST!)) {
return NextResponse.json({ error: "Bad origin" }, { status: 400 });
}
const json = await req.json().catch(() => ({}));
const parse = Body.safeParse(json);
if (!parse.success) return NextResponse.json({ error: "Bad payload" }, { status: 400 });
// 1 h is fine, but session cookies can be longer; cap at <= 14 days per Firebase.
const expiresIn = 60 * 60 * 1000; // 1 hour
const sessionCookie = await adminAuth.createSessionCookie(parse.data.idToken, { expiresIn });
const res = NextResponse.json({ ok: true });
res.cookies.set({
name: "session",
value: sessionCookie,
httpOnly: true,
secure: true,
sameSite: "lax", // consider "strict" if UX allows
maxAge: expiresIn / 1000,
path: "/",
});
return res;
}
Why Node.js runtime. Firebase Admin requires Node built-ins; Edge runtime lacks them. This avoids os
/fs
module errors.
Reading the user in Server Components
Verify once per request in a tiny helper. Use checkRevoked: true
if you need immediate revocation semantics.
// lib/server/current-user.ts
export const runtime = "nodejs";
import { cookies } from "next/headers";
import { adminAuth } from "@/externals/firebase/server";
export async function getCurrentUser() {
const cookieStore = await cookies();
const session = cookieStore.get("session")?.value;
if (!session) return null;
try {
const decoded = await adminAuth.verifySessionCookie(session, true); // checkRevoked = true
return { uid: decoded.uid, email: decoded.email ?? null };
} catch {
return null;
}
}
Implications. checkRevoked: true
adds a revocation lookup. Use it for admin areas and payment flows, or set it to false
for lower-risk reads to reduce latency.
Logout that actually logs out
Clear the cookie server-side. Optionally revoke refresh tokens to force all sessions closed, and sign out the client to clear in-memory state.
// app/api/session/route.ts (DELETE)
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { adminAuth } from "@/externals/firebase/server";
import { cookies } from "next/headers";
export async function DELETE() {
const cookieStore = await cookies();
const session = cookieStore.get("session")?.value;
if (session) {
try {
const { uid } = await adminAuth.verifySessionCookie(session);
await adminAuth.revokeRefreshTokens(uid); // optional but strong
} catch { /* ignore */ }
}
const res = NextResponse.json({ ok: true });
res.cookies.set({
name: "session",
value: "",
httpOnly: true,
secure: true,
sameSite: "lax",
expires: new Date(0),
path: "/",
});
return res;
}
Client hook:
// app/(auth)/logout.ts
"use client";
import { signOut } from "firebase/auth";
import { auth } from "@/externals/firebase/client";
export async function logout() {
await fetch("/api/session", { method: "DELETE", credentials: "include" });
await signOut(auth); // clear client state
}
Firebase documents revoking refresh tokens and verifying session cookies during sign-out.
RSC integration pattern
In any Server Component or loader, call your helper and branch on the result:
// app/dashboard/page.tsx
import { getCurrentUser } from "@/lib/server/current-user";
export default async function Dashboard() {
const user = await getCurrentUser();
if (!user) {
// Render a login CTA or redirect via a client boundary.
return <p>Sign in to continue.</p>;
}
return <div>Hello {user.email ?? user.uid}</div>;
}
Guardrails and optimizations
Choose sensible expirations. Session cookies can last up to 14 days, independent of the 1 h ID token lifetime. Pick shorter for admin areas; consider longer for consumer apps with strict revocation checks at critical endpoints.
Keep Admin SDK off Edge. Do not use Admin SDK in Middleware or Edge Route Handlers; use Node runtime endpoints. If you need auth hints in Middleware for routing, rely on lightweight signals and defer verification to Node handlers.
Harden the session exchange. Treat Server Actions and Route Handlers as public APIs. Validate payloads, require JSON POST
, check Origin
or Host
, and rate-limit if exposed to the internet.
Cookie settings. Always set HttpOnly
, Secure
, SameSite=Lax
or Strict
, and Path=/
. Consider Domain
only if you intentionally share across subdomains. Use Strict
where you can to reduce CSRF exposure. Official Next.js cookies()
supports reads in RSC and writes in Actions/Route Handlers.
Understand scope limits. Firebase session cookies do not authenticate client SDK calls to Firestore or Realtime Database. Use your server as the gateway for privileged operations when you choose this model.
Stay current with Next.js. Next.js 15.x emphasizes stability in App Router and Server Actions; the patterns above match current guidance.
When to pick Firebase Auth over other providers
Supabase Auth, Clerk, and Auth.js integrate well with Next.js, yet Firebase Auth remains an effective choice when you already use Firebase, need Google’s identity providers, or prefer managed MFA and action links. Session cookies give you a clean RSC-friendly, server-centric contract without hydrating client state in every component.