Signing out in a modern Next.js app that uses Firebase has two moving parts: client state and server session. On the client, Firebase Auth tracks the user in memory and local browser storage. On the server, production apps typically exchange the client’s ID token for a signed session cookie to enable SSR, middleware checks, and API protection.
A correct “Sign out” flow must clear both. You will work with the Next.js 15 App Router and the latest Firebase JS SDK and Admin SDK. You’ll deploy anywhere you like—if you are planning to host on DigitalOcean, make sure you have an account handy so you can wire environment variables and HTTPS early.
You’ll implement a sign-out button that:
- calls
signOut()
on the client to clear local Firebase state; - calls a server Route Handler that deletes the secure session cookie and invalidates the session;
- redirects the user to a safe page.
This guide covers two common setups:
- Client-only tokens (SPA-style): You do not mint server session cookies. Sign-out is entirely client-side.
- SSR with Firebase session cookies (recommended): You mint a secure HTTP-only cookie (often named
__session
) via the Admin SDK. Sign-out must delete that cookie on the server.
Prerequisites and packages
- Next.js 15 (App Router).
- Firebase JS SDK (browser) and Firebase Admin SDK (server).
- Environment variables for Firebase client (public) and service account (server).
Install:
npm i firebase
npm i -D @types/jsonwebtoken
npm i firebase-admin
If you have not already, initialize Firebase on the server using the Admin SDK with a service account JSON or equivalent environment variables.
Project structure and initialization
Create two small helpers: one for the client SDK, one for the Admin SDK.
Create lib/firebaseClient.ts
:
// lib/firebaseClient.ts
import { initializeApp, getApps, getApp } 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 firebaseApp = getApps().length ? getApp() : initializeApp(firebaseConfig);
export const clientAuth = getAuth(firebaseApp);
Create lib/firebaseAdmin.ts
:
// lib/firebaseAdmin.ts
import { initializeApp, getApps, cert } from "firebase-admin/app";
import { getAuth } from "firebase-admin/auth";
const app = getApps().length
? getApps()[0]
: initializeApp({
credential: cert({
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
// If using a base64-encoded key in env, decode here
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, "\n"),
}),
});
export const adminAuth = getAuth(app);
If your app uses SSR-protected routes, you should exchange the client’s ID token for a session cookie via a Route Handler on sign-in. This is shown here because it defines what you must later delete on sign-out.
Create app/api/auth/session/route.ts
:
// app/api/auth/session/route.ts
import { adminAuth } from "@/lib/firebaseAdmin";
import { cookies } from "next/headers";
export async function POST(request: Request) {
const { idToken } = await request.json();
// 1–14 days is typical; choose shorter for sensitive apps
const expiresIn = 60 * 60 * 24 * 5 * 1000; // 5 days in ms
const sessionCookie = await adminAuth.createSessionCookie(idToken, { expiresIn });
// Secure, HTTP-only cookie; SameSite=strict to avoid CSRF via cross-site requests
cookies().set("__session", sessionCookie, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/",
maxAge: Math.floor(expiresIn / 1000),
});
return new Response(null, { status: 204 });
}
This handler is called after a successful client sign-in (e.g., OAuth). It mints a session cookie that your middleware/API can verify. You will remove this cookie on sign-out.
Create a small client component that does two things in order: (1) signOut(clientAuth)
and (2) call your server logout
handler to delete the session cookie.
Create components/SignOutButton.tsx
:
// components/SignOutButton.tsx
"use client";
import { signOut } from "firebase/auth";
import { clientAuth } from "@/lib/firebaseClient";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function SignOutButton() {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function handleSignOut() {
try {
setBusy(true);
// 1) Clear client-side Firebase state (local storage, in-memory, listeners)
await signOut(clientAuth);
// 2) Ask the server to clear the HTTP-only session cookie(s)
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
// 3) Route away from protected pages
router.replace("/login");
router.refresh();
} finally {
setBusy(false);
}
}
return (
<button onClick={handleSignOut} disabled={busy} aria-busy={busy}>
{busy ? "Signing out…" : "Sign out"}
</button>
);
}
signOut()
clears the Firebase client session, but it does not remove your HTTP-only session cookie; the server must delete it. That is why the button calls a Route Handler next.
Create app/api/auth/logout/route.ts
. Use the App Router cookies()
API to delete the cookie by name. You may also choose to revoke refresh tokens to immediately invalidate other sessions issued for the same user.
// app/api/auth/logout/route.ts
import { cookies } from "next/headers";
import { adminAuth } from "@/lib/firebaseAdmin";
export async function POST() {
// If you track the UID in a secondary cookie, read it here before deletion.
const session = cookies().get("__session")?.value;
// Delete server cookies FIRST to avoid races with subsequent requests.
cookies().delete("__session");
// Optional hardening: revoke refresh tokens for the current user.
// This requires verifying the session cookie to obtain the UID.
if (session) {
try {
const decoded = await adminAuth.verifySessionCookie(session, true);
await adminAuth.revokeRefreshTokens(decoded.sub);
} catch {
// Best-effort: ignore verification errors at sign-out
}
}
// Returning 204 is fine; the client will redirect.
return new Response(null, { status: 204 });
}
Key constraints: with the App Router, cookie mutation must happen in a Route Handler or Server Action—doing this in a server component will throw. The .delete()
call removes the cookie by setting an expired Set-Cookie header; equivalently, you could set maxAge: 0
.
If you built a purely client-side app and never minted a session cookie, the sign-out flow is minimal. You only need the button component from earlier, and you can skip the /api/auth/logout
call. For consistency (and future SSR), you can still call the server endpoint; it will no-op if the cookie is missing.
// components/SignOutButtonClientOnly.tsx
"use client";
import { signOut } from "firebase/auth";
import { clientAuth } from "@/lib/firebaseClient";
export default function SignOutButtonClientOnly() {
return (
<button
onClick={async () => {
await signOut(clientAuth);
// No server cookie to delete in this setup
window.location.assign("/login");
}}
>
Sign out
</button>
);
}
This clears IndexedDB/localStorage and in-memory state created by the Firebase JS SDK, which is sufficient when you never relied on server-side cookies.
Protecting routes and verifying sign-out end to end
If you use middleware or server components to guard pages, ensure they read and verify the session cookie and react properly when it’s missing after logout.
Create middleware.ts
to gate paths under /dashboard
:
// middleware.ts
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { adminAuth } from "@/lib/firebaseAdmin";
export async function middleware(req: NextRequest) {
const session = req.cookies.get("__session")?.value;
// If there is no cookie after logout, redirect to /login
if (!session) {
return NextResponse.redirect(new URL("/login", req.url));
}
try {
// Verify session; 'checkRevoked: true' makes revocations effective immediately
await adminAuth.verifySessionCookie(session, true);
return NextResponse.next();
} catch {
// Invalid or revoked: clear and redirect
const res = NextResponse.redirect(new URL("/login", req.url));
res.cookies.delete("__session");
return res;
}
}
export const config = {
matcher: ["/dashboard/:path*"],
};
This gives you a reliable end-to-end sign-out: the cookie disappears, middleware sees no session, and it redirects.
Common pitfalls and how to avoid them
Trying to delete cookies inside a server component or layout. This fails at runtime. Move cookie deletion into a Route Handler (app/api/.../route.ts
) or a Server Action.
Deleting only on the client. signOut()
does not touch HTTP-only cookies. Always call your server logout endpoint when you use session cookies.
Relying solely on client redirects. After logout, call router.replace("/login")
and router.refresh()
to clear cached data and avoid a “flash” of protected content.
Not revoking tokens when security matters. If a device is lost or a session is suspected compromised, add revokeRefreshTokens(uid)
at logout or in an admin path and ensure verification uses checkRevoked: true
. This forces reauthentication across devices.
Cookie name mismatch. Use one canonical name (e.g., __session
) everywhere: minting, reading, deleting. The name itself is not special, but __session
has convention and middleware friendliness.
Testing the flow
- Sign in and confirm the
__session
cookie is present in the Application tab. - Click Sign out. The client state clears; the browser now sends a request to
/api/auth/logout
. - Observe the
Set-Cookie: __session=; Max-Age=0; ...
header in the Network tab; the cookie disappears. - Navigate to a protected route; middleware should redirect to
/login
.
The App Router cookie API guarantees that calling .delete("name")
in a Route Handler produces a proper Set-Cookie
removal header; you can also verify by setting maxAge: 0
yourself if you prefer.
Wrapping up
You implemented a sign-out that clears both the Firebase client session and the server session cookie, and you wired middleware to react to the missing or revoked session. This pattern aligns with Next.js 15’s App Router APIs and Firebase’s session cookie model, and it scales cleanly to SSR, API routes, and multi-device logout.
If you are deploying to DigitalOcean, set the Firebase environment variables in your App Platform settings and ensure HTTPS so the secure
cookie attribute is honored. From here, integrate the same /api/auth/logout
endpoint into any “Sign out” UI to keep your session logic consistent across pages and devices.
Appendix: Minimal sign-in to session (for completeness)
This shows how a client signs in, then posts its ID token to your session endpoint for cookie minting—completing the circle with the earlier logout.
// components/SignInWithGoogle.tsx
"use client";
import { clientAuth } from "@/lib/firebaseClient";
import { GoogleAuthProvider, signInWithPopup, getIdToken } from "firebase/auth";
import { useRouter } from "next/navigation";
export default function SignInWithGoogle() {
const router = useRouter();
async function signIn() {
const provider = new GoogleAuthProvider();
const credential = await signInWithPopup(clientAuth, provider);
const idToken = await getIdToken(credential.user, true);
// Exchange for server session cookie
await fetch("/api/auth/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ idToken }),
});
router.replace("/dashboard");
router.refresh();
}
return <button onClick={signIn}>Sign in with Google</button>;
}
The sign-in endpoint uses createSessionCookie()
as shown earlier; your logout endpoint deletes the cookie and optionally revokes tokens, keeping the lifecycle tight and predictable.