Client-only Firebase apps have an easy auth story: the SDK holds the user, refreshes the ID token in the background, and the Firestore client attaches it to every request. Move rendering to the server — Next.js App Router, SvelteKit, Nuxt, anything on App Hosting or Cloud Run — and that story breaks in a specific way.
On a hard navigation, the server renders before any JavaScript runs. auth.currentUser is null because there is no auth. The server has exactly one thing to work with: the cookies on the request. If you have not put an auth credential in a cookie, your server-rendered page cannot know who the user is, and you end up shipping a logged-out shell that flickers into a logged-in page a second later.
The standard answers, in the order teams usually try them:
- Write the ID token into a cookie from the client. Works for about an hour, then the token expires and the server starts rejecting valid sessions.
- Write the refresh token into a cookie. Now a single stolen cookie mints tokens indefinitely, and you cannot revoke it without revoking every session the user has.
- Exchange the ID token for a session cookie via the Admin SDK. This is the supported path, and it is what the rest of this post covers.
What a session cookie actually is
getAuth().createSessionCookie() takes a fresh ID token and returns a signed JWT with a duration you choose, from 5 minutes to 14 days. It is not the ID token and it is not the refresh token. It carries the same claims — uid, email, custom claims — and it is verifiable by your server without a round trip to Google in the common case.
// POST /api/session — runs on the server, Admin SDK
import { getAuth } from 'firebase-admin/auth';
export async function POST(request) {
const { idToken } = await request.json();
const expiresIn = 60 * 60 * 24 * 5 * 1000; // 5 days
const decoded = await getAuth().verifyIdToken(idToken, true);
// Refuse stale logins: require the sign-in to be recent.
if (Date.now() / 1000 - decoded.auth_time > 5 * 60) {
return new Response('recent sign-in required', { status: 401 });
}
const cookie = await getAuth().createSessionCookie(idToken, { expiresIn });
return new Response(null, {
status: 204,
headers: {
'Set-Cookie': `__session=${cookie}; Max-Age=${expiresIn / 1000}; Path=/; HttpOnly; Secure; SameSite=Lax`,
},
});
}
The auth_time check is the part people skip. Without it, an attacker holding any still-valid ID token — one scraped from a log, say — can mint a five-day session. With it, the token has to come from a sign-in that happened in the last five minutes.
The client side is three lines, called once after sign-in:
const cred = await signInWithEmailAndPassword(auth, email, password);
await fetch('/api/session', {
method: 'POST',
body: JSON.stringify({ idToken: await cred.user.getIdToken() }),
});
The cookie name matters on Firebase Hosting
If you are behind Firebase Hosting's CDN, __session is the only cookie forwarded to your backend. Every other cookie is stripped so responses stay cacheable. Name it anything else and your server sees no cookie at all, with no error to explain why. App Hosting and plain Cloud Run do not have this restriction, but __session is a harmless default everywhere and it saves the debugging session.
Verifying on the server
import { cookies } from 'next/headers';
import { getAuth } from 'firebase-admin/auth';
export async function currentUser() {
const cookie = (await cookies()).get('__session')?.value;
if (!cookie) return null;
try {
return await getAuth().verifySessionCookie(cookie, false);
} catch {
return null;
}
}
That second argument is the whole performance and security tradeoff, so be deliberate about it.
false (default): signature and expiry are checked locally against Google's public keys, which the Admin SDK caches in process. No network call, sub-millisecond, no quota. A revoked session keeps working until the cookie expires.
true: the SDK additionally checks whether the session was revoked, which means a lookup against the Auth backend. Budget roughly 30–80 ms of added latency per request from a warm instance in the same region, more on a cold one. On a page that renders five server components each asking who the user is, that is five lookups unless you memoize.
Practical split: false for rendering — nav bars, personalization, deciding which layout to draw — and true on the operations that actually matter, such as changing an email address, rotating billing details, or deleting an account. Rendering a stale nav bar for a revoked user is cosmetic. Letting one change a password is not.
And memoize the verification per request so a page with a dozen components pays for it once:
import { cache } from 'react';
export const currentUser = cache(async () => { /* as above */ });
Revocation, properly
Sign-out has to clear both halves. Deleting the cookie logs the browser out; it does nothing to a copy of the cookie someone else holds.
export async function POST() {
const cookie = (await cookies()).get('__session')?.value;
if (cookie) {
const decoded = await getAuth().verifySessionCookie(cookie).catch(() => null);
if (decoded) await getAuth().revokeRefreshTokens(decoded.sub);
}
(await cookies()).delete('__session');
return new Response(null, { status: 204 });
}
revokeRefreshTokens bumps the user's tokensValidAfterTime. Every session cookie minted before that instant now fails verifySessionCookie(cookie, true) — and only that call, which is why the checked verification belongs on your sensitive paths. It also invalidates the user's other devices, which is usually what "sign out everywhere" should do but is occasionally a surprise for people who expected per-device sessions. If you need per-device revocation, you are past what Firebase Auth gives you and into your own session table.
The client still needs a client session
A session cookie does not authenticate the Firestore or Storage client SDKs. Those want an ID token, and security rules evaluate request.auth from that token, not from your cookie. So you have two sessions in flight: the cookie your server trusts, and the SDK's own persisted session in IndexedDB.
They drift. The usual symptom is a redirect loop: the cookie expired, the server bounces to /login, the client SDK still has a valid user, sees someone "already signed in", and bounces back. Break it by making the client the follower — when the server says unauthenticated, tear down the client session explicitly rather than trusting what is in IndexedDB.
The other drift direction is silent and more expensive: the client SDK refreshes tokens happily for days while your cookie expires on day five, so the user keeps reading Firestore directly from the browser while your server-rendered pages insist they are logged out. Match the cookie lifetime to the session lifetime you actually intend, and refresh the cookie on activity — an onIdTokenChanged listener that re-posts to /api/session when the token rotates keeps them aligned for the cost of one Admin SDK call per hour per active user.
onIdTokenChanged(auth, async (user) => {
if (!user) return;
await fetch('/api/session', {
method: 'POST',
body: JSON.stringify({ idToken: await user.getIdToken() }),
});
});
What this costs
The exchange endpoint is one Admin SDK call per sign-in and per hourly token rotation. For 10,000 daily active users averaging three hours of session time, that is on the order of 40,000 calls a day — trivial against Auth quotas, but it is 40,000 invocations of whatever is serving that route. If it is a Cloud Function, give it minimum instances or accept a cold start on the login path, which is the worst possible place for a two-second stall.
Verification is free when unchecked and a network call when checked. That single boolean is the only auth line item most SSR Firebase apps have, and it is worth being explicit about which pages pay it.
The checks we run on an audit
- Cookie flags:
HttpOnly,Secure,SameSite=LaxorStrict. A session cookie readable from JavaScript is a session cookie one XSS away from being stolen. auth_timeenforced at exchange time, so an old ID token cannot buy a new long-lived session.checkRevoked: trueon the account-mutating routes, not on every render.- Sign-out revokes refresh tokens rather than only deleting the cookie.
- Cookie named
__sessionif anything in the path is Firebase Hosting. - Cookie lifetime matched to the product's intended session length, not left at 14 days because that was the maximum in the example.
None of this is exotic. It is six checks, and in the reviews we run, most SSR Firebase apps miss at least three.