Modern Next.js apps often mix static UI with server-rendered data that must be user-specific. Firebase Authentication gives you client-side identity via ID tokens, but server rendering and route protection work best with HTTP-only session cookies. In App Router projects, there’s one wrinkle: Middleware runs on the Edge runtime, which cannot run the Firebase Admin SDK directly.
The pattern below solves this by setting and verifying Firebase session cookies on the Node.js runtime (Route Handlers and Server Components) while keeping Middleware light for fast redirects. You’ll deploy comfortably to your preferred platform.
What you’ll build:
- Sign in on the client with Firebase Web SDK and post the ID token to a Route Handler.
- Exchange the ID token for a Firebase session cookie (HTTP-only, Secure, SameSite).
- Verify the cookie on the server (Node runtime) to render protected content.
- Use Middleware only for quick gating/redirects, not cryptographic verification (because Admin SDK can’t run at the Edge).
Versions, assumptions, and project shape
- Next.js 15.x with the App Router (
app/
), Node ≥20, React 19 compatibility. - Firebase Web SDK 12.x and Firebase Admin Node SDK 13.x.
- You’ll keep secrets in env vars; the Firebase Admin service account JSON is provided via an environment variable.
The app structure (relevant parts):
app/
(public)/
login/page.tsx
(protected)/
layout.tsx
page.tsx
api/
auth/
login/route.ts
logout/route.ts
lib/
firebase/
client.ts
admin.ts
auth/
session.ts
middleware.ts
.env.local (development only)
1) Install and configure Firebase
Install client and admin SDKs:
npm i firebase
npm i -S firebase-admin
Client: initialize once
lib/firebase/client.ts
import { initializeApp, getApps } from 'firebase/app';
import { getAuth } from 'firebase/auth';
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN!,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID!,
};
export const clientApp =
getApps().length ? getApps()[0]! : initializeApp(firebaseConfig);
export const clientAuth = getAuth(clientApp);
Admin: initialize on the server (Node runtime only)
Do not import this from Middleware. Keep this confined to Route Handlers and Server Components.
lib/firebase/admin.ts
import { App, cert, getApps, initializeApp } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';
let adminApp: App;
if (!getApps().length) {
const serviceAccount = JSON.parse(
// Store the raw JSON or a base64-encoded string in the env var
process.env.FIREBASE_SERVICE_ACCOUNT as string
);
adminApp = initializeApp({
credential: cert(serviceAccount),
projectId: serviceAccount.project_id,
});
} else {
adminApp = getApps()[0]!;
}
export const adminAuth = getAuth(adminApp);
This satisfies Admin SDK requirements (project ID via service account) and avoids multiple initializations in hot reload.
A session cookie keeps users signed in for SSR/Server Components without exposing tokens to JavaScript. You’ll create it from a fresh ID token and verify it server-side when rendering protected UI.
lib/auth/session.ts
import { cookies } from 'next/headers';
import { adminAuth } from '@/lib/firebase/admin';
const COOKIE_NAME = '__Host-fb-session';
const MAX_AGE_MS = 5 * 24 * 60 * 60 * 1000; // 5 days (<= 14d per Firebase)
const MAX_AGE_S = Math.floor(MAX_AGE_MS / 1000);
export async function createSessionCookie(idToken: string) {
const sessionCookie = await adminAuth.createSessionCookie(idToken, {
expiresIn: MAX_AGE_MS,
});
// HttpOnly cookie; "__Host-" prefix requires Secure, path="/", no Domain
cookies().set({
name: COOKIE_NAME,
value: sessionCookie,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: MAX_AGE_S,
});
return sessionCookie;
}
export function clearSessionCookie() {
cookies().set({
name: COOKIE_NAME,
value: '',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 0,
});
}
export async function verifySessionFromCookies() {
const cookieStore = cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) return null;
try {
const decoded = await adminAuth.verifySessionCookie(token, true);
return decoded;
} catch {
return null;
}
}
Firebase supports creating and verifying session cookies with max lifetime ≤14 days; verification requires the Admin SDK.
3) Route Handlers for login and logout
These run on the Node runtime, so Admin SDK is available.
app/api/auth/login/route.ts
import { NextResponse } from 'next/server';
import { createSessionCookie } from '@/lib/auth/session';
export async function POST(req: Request) {
const { idToken } = await req.json();
if (!idToken) {
return NextResponse.json({ error: 'Missing idToken' }, { status: 400 });
}
await createSessionCookie(idToken);
return NextResponse.json({ ok: true });
}
app/api/auth/logout/route.ts
import { NextResponse } from 'next/server';
import { adminAuth } from '@/lib/firebase/admin';
import { clearSessionCookie, verifySessionFromCookies } from '@/lib/auth/session';
export async function POST() {
const decoded = await verifySessionFromCookies();
if (decoded) {
// Optional: revoke refresh tokens to immediately invalidate existing cookies
await adminAuth.revokeRefreshTokens(decoded.sub);
}
clearSessionCookie();
return NextResponse.json({ ok: true });
}
Your UI can use Google Sign-In, email/password, etc. After sign-in, get the ID token and post it to /api/auth/login
.
app/(public)/login/page.tsx
'use client';
import { clientAuth } from '@/lib/firebase/client';
import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth';
import { useState } from 'react';
export default function LoginPage() {
const [busy, setBusy] = useState(false);
async function loginWithGoogle() {
setBusy(true);
try {
const provider = new GoogleAuthProvider();
const cred = await signInWithPopup(clientAuth, provider);
const idToken = await cred.user.getIdToken(/* forceRefresh? */ true);
await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// Important: credentials NOT required because server sets HttpOnly cookie
body: JSON.stringify({ idToken }),
});
// Navigate to protected area
window.location.href = '/';
} finally {
setBusy(false);
}
}
return (
<main className="p-8">
<button onClick={loginWithGoogle} disabled={busy}>
{busy ? 'Signing in…' : 'Sign in with Google'}
</button>
</main>
);
}
This pattern keeps tokens off the client after the initial exchange and avoids XSS access to auth state.
5) Protect routes: Middleware for UX, server verification for security
Why split responsibility? Middleware runs at the Edge runtime and cannot import firebase-admin
. Use Middleware to quickly gate navigation (presence check + redirect). Then, in Server Components or Route Handlers, call verifySessionFromCookies()
for the authoritative check before returning data/HTML. This keeps security correct while preserving fast redirects.
middleware.ts
import { NextResponse, NextRequest } from 'next/server';
const COOKIE_NAME = '__Host-fb-session';
// Protect everything in /(protected) and any API under /api/private
const PROTECTED_MATCHERS = [/^\/(protected)(\/|$)/, /^\/api\/private(\/|$)/];
export function middleware(req: NextRequest) {
const url = new URL(req.url);
const isProtected = PROTECTED_MATCHERS.some((re) => re.test(url.pathname));
if (!isProtected) return NextResponse.next();
const hasSession = req.cookies.has(COOKIE_NAME);
if (!hasSession) {
const loginUrl = new URL('/login', url.origin);
loginUrl.searchParams.set('next', url.pathname);
return NextResponse.redirect(loginUrl);
}
// Presence check only; actual verification happens on the server.
return NextResponse.next();
}
export const config = {
matcher: ['/protected/:path*', '/api/private/:path*'],
};
Why not verify in Middleware? Firebase Admin relies on Node APIs not available in the Edge runtime; doing cryptographic verification here is not supported. Keep the real check on the Node runtime.
6) Verify on the server before rendering protected UI
Use a Server Component layout to gate an entire segment. If the session is invalid, redirect to /login
.
app/(protected)/layout.tsx
import { ReactNode } from 'react';
import { redirect } from 'next/navigation';
import { verifySessionFromCookies } from '@/lib/auth/session';
export default async function ProtectedLayout({ children }: { children: ReactNode }) {
const decoded = await verifySessionFromCookies();
if (!decoded) redirect('/login');
// decoded contains standard Firebase JWT claims, e.g., sub (uid), email, etc.
return (
<section>
{children}
</section>
);
}
You can now safely read decoded.sub
to fetch user-specific data in loaders or server actions for pages inside (protected)
.
app/(protected)/page.tsx
'use client';
export default function DashboardPage() {
async function logout() {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.href = '/login';
}
return (
<main className="p-8">
<h1>Dashboard</h1>
<button onClick={logout}>Sign out</button>
</main>
);
}
8) Environment variables
Client (public):
NEXT_PUBLIC_FIREBASE_API_KEY
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN
NEXT_PUBLIC_FIREBASE_PROJECT_ID
NEXT_PUBLIC_FIREBASE_APP_ID
Server (secret):
FIREBASE_SERVICE_ACCOUNT
— the entire service account JSON (either raw or base64-encoded string). The Admin SDK needs a project ID to verify session cookies.
On local dev, place public keys in .env.local
. Keep the service account out of files; inject via your platform’s secret manager.
9) Deployment on DigitalOcean (App Platform or Droplet)
App Platform (managed):
- Push your repository and create a new App from GitHub in App Platform.
- The platform detects Next.js automatically.
- Add the environment variables above. For the service account, add
FIREBASE_SERVICE_ACCOUNT
as an encrypted variable (paste raw JSON or base64). - Ensure the default Node version is ≥20 (set
NODE_VERSION=20
if needed). - Deploy; App Platform builds and serves your App Router project.
Droplet (DIY): build with npm run build
, serve with npm start
behind Nginx or a process manager; export the same env vars in your service file.
10) Testing the flow locally
npm run dev
and open/login
.- Sign in, watch the network tab for
POST /api/auth/login
. - Confirm the
__Host-fb-session
cookie is set as HttpOnly. - Navigate to
/protected
; the layout verifies the cookie and renders. - Try deleting the cookie, then reload
/protected
—you should be redirected to/login
.
11) Hardening and notes
- Cookie attributes: Keep
HttpOnly
,Secure
(in production),SameSite=Lax
,Path=/
, and the__Host-
prefix for stricter scoping. - Revocation: On sensitive apps, call
revokeRefreshTokens(uid)
on logout to invalidate all sessions quickly. - Refresh window: A session cookie is not auto-refreshing. Choose a reasonable
expiresIn
(for example, 5–7 days) and let users re-authenticate when it expires. - Middleware limits: Don’t import
firebase-admin
inmiddleware.ts
. Keep verification in Route Handlers/Server Components (Node runtime). - Static vs dynamic: For static segments that still require gating, Middleware is appropriate to block navigation, but you must still verify on the server before returning protected data.
- Upgrades: Next.js 15 (App Router) and the latest Firebase SDKs continue to refine APIs; check release notes during upgrades, especially around Node engine minimums and React 19 alignment.
Recap
You signed in with Firebase on the client, traded the ID token for an HTTP-only session cookie, and verified it on the server to protect App Router routes. Middleware performed a quick presence check for smooth UX, while server code enforced security with the Firebase Admin SDK. This split respects the Edge runtime’s constraints and gives you SSR-friendly, cookie-based auth that deploys cleanly to DigitalOcean. From here, layer in role checks (custom claims), per-request data loading, and audit logging as your app grows.
References for constraints mentioned (for your awareness)
- Edge runtime constraints and Middleware usage; App Router auth guidance.
- Firebase session cookie creation and verification; project ID requirement; revocation.
- Next.js 15 context.
- DigitalOcean App Platform Next.js sample and Droplet guide.