How to handle token expiration and auto-refresh with Firebase in Next

How to handle token expiration and auto-refresh with Firebase in Next.js

We may earn an affiliate commission through purchases made from our guides and tutorials.

When a user signs in via Firebase Authentication, the system issues two relevant tokens:

  • ID token: a JWT that identifies the user and is used to make authenticated API requests. It’s short lived (≈ 1 hour)
  • Refresh token: a long-lived credential that lets the client obtain a new ID token when the current one expires. Firebase refresh tokens only expire in certain cases (e.g. user deletion, disabling account, password change).

Because ID tokens expire, your client or server logic must detect expiration (or preempt it) and trigger a refresh so as not to interrupt the user experience.

One helpful fact is: the Firebase Auth client SDK already handles ID token refresh under many circumstances. In particular:

  • If you call user.getIdToken() and the existing token is expired, the SDK will attempt to refresh it before returning a valid token.
  • The onIdTokenChanged() listener fires not only when the user signs in/out, but also when their ID token is refreshed.

Thus, you don’t always need to reinvent token refresh. But you do need to integrate that into your Next.js data flow (server components, API routes, middleware) so that stale or expired tokens don’t cause silent failures.

Challenges in Next.js + Firebase: SSR / middleware / stale tokens

Here are the main friction points to solve:

  • When doing server-side rendering (SSR) or server components, you may receive an expired token in a cookie or header, and you must decide how to refresh it transparently.
  • In long-lived client-side sessions (e.g. user leaves a tab open for over an hour), cookies or server-side tokens may become stale, and the user might need a “refresh endpoint” to revalidate.
  • In Next.js middleware or edge runtimes, you may not have access to Node’s APIs (for crypto, etc.), so libraries must support edge-compatible JWT verification.

To simplify much of this, many developers adopt a wrapper library such as next-firebase-auth-edge, which handles a lot of cookie signing, token refresh endpoints, and middleware logic for you. In what follows, I present a pattern using that library. You can adapt these ideas even if you roll your own.

High-level architecture

Here’s the flow I recommend:

  1. On client sign-in, get the Firebase ID token and send it to a Next.js login API (or trigger middleware) which issues a signed cookie (or set of cookies) that your server can verify.
  2. Use Next.js middleware (or edge-capable auth middleware) on protected routes to decode and verify that cookie. If the cookie’s ID token is expired or invalid, route through a refresh token endpoint.
  3. The refresh endpoint (e.g. /api/refresh-token) uses the stored refresh token or signs a new ID token (via Firebase) and issues a fresh signed cookie, then returns the new ID token to the client.
  4. On the client side, before making fetch/API calls, always call getValidIdToken(...) (or equivalent) to ensure you send a current ID token.
  5. Use onIdTokenChanged() + a periodic interval (e.g. every 10 min) to force-refresh the ID token proactively, and to synchronize the cookie state.

With this in place, your server-side routes, server components, and client API calls can all rely on a valid token.

Walkthrough with next-firebase-auth-edge

Below is a realistic setup using this library, which is designed to work in both Node and Edge runtimes and supports automatic refresh.

1. Install and basic setup

// package.json deps
"dependencies": {
  "firebase": "^X.Y.Z",
  "next-firebase-auth-edge": "^latest",
  "nookies": "^2.5.2"  // for cookie helpers
}

Create a file like lib/auth.ts:

import { authMiddleware, init } from "next-firebase-auth-edge";

// Initialize config for the middleware
init({
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,
  cookieSignatureKeys: [process.env.COOKIE_KEY1!, process.env.COOKIE_KEY2!],
  // (Optional) enable token refresh endpoint
  enableTokenRefreshOnExpiredKidHeader: true,
});

Then in middleware.ts at the root (or alongside your app router):

import { authMiddleware } from "next-firebase-auth-edge";
import { NextRequest } from "next/server";

export async function middleware(req: NextRequest) {
  return authMiddleware(req, {
    loginPath: "/api/login",
    logoutPath: "/api/logout",
    refreshTokenPath: "/api/refresh-token",
  });
}

export const config = {
  matcher: [
    "/api/login",
    "/api/logout",
    "/api/refresh-token",
    "/((?!_next|favicon.ico|api|.*\\.).*)"
  ],
};

This ensures that protected routes will pass through the auth logic. The refreshTokenPath option signals the middleware to auto-invoke a refresh flow when the cookie is expired.

2. Login / logout / refresh API routes

You’ll need to define endpoints for login, logout, and refresh. For example: pages/api/login.ts:

import { NextApiRequest, NextApiResponse } from "next";
import { setAuthCookies } from "next-firebase-auth-edge";
import { getAuth } from "firebase-admin";
import admin from "../../lib/firebaseAdmin";  // your Admin SDK wrapper

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== "POST") {
    res.status(405).end();
    return;
  }
  const { idToken } = req.body;
  try {
    // Optionally verify via admin
    const decoded = await admin.auth().verifyIdToken(idToken);
    // Set auth cookies (signed) — this is provided by the library
    await setAuthCookies(req, res, { idToken });
    res.status(200).json({ success: true });
  } catch (err) {
    console.error("Login API error:", err);
    res.status(401).json({ error: "Invalid token" });
  }
}

api/logout.ts:

import { NextApiRequest, NextApiResponse } from "next";
import { clearAuthCookies } from "next-firebase-auth-edge";

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  clearAuthCookies(req, res);
  res.status(200).json({ success: true });
}

api/refresh-token.ts:

import { NextApiRequest, NextApiResponse } from "next";
import { refreshAuth } from "next-firebase-auth-edge";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  try {
    const result = await refreshAuth(req, res);
    res.status(200).json({ idToken: result.idToken });
  } catch (err) {
    console.error("Refresh API error:", err);
    res.status(401).json({ error: "Unable to refresh token" });
  }
}

Under the hood, refreshAuth will detect an expired cookie, request a fresh token, and issue new cookies (if configured).

3. Client-side logic: syncing token & refresh

On the client side, wrap your app with an auth context:

// lib/AuthProvider.tsx
"use client";

import { useEffect, createContext, useContext, useState } from "react";
import { getAuth } from "firebase/auth";
import nookies from "nookies";

const AuthContext = createContext<{ user: any }>({ user: null });

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<any>(null);

  useEffect(() => {
    const auth = getAuth();

    return auth.onIdTokenChanged(async (firebaseUser) => {
      if (!firebaseUser) {
        setUser(null);
        nookies.set(undefined, "token", "", { path: "/" });
      } else {
        const token = await firebaseUser.getIdToken();
        setUser(firebaseUser);
        nookies.set(undefined, "token", token, { path: "/" });
      }
    });
  }, []);

  // force-refresh token every 10 min to avoid staleness
  useEffect(() => {
    const interval = setInterval(async () => {
      const auth = getAuth();
      const u = auth.currentUser;
      if (u) {
        await u.getIdToken(true);  // `true` forces refresh
      }
    }, 10 * 60 * 1000);
    return () => clearInterval(interval);
  }, []);

  return <AuthContext.Provider value={{ user }}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  return useContext(AuthContext);
}

Wrap your app (maybe in app/layout.tsx or pages/_app.tsx):

import { AuthProvider } from "../lib/AuthProvider";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <AuthProvider>
      {children}
    </AuthProvider>
  );
}

Whenever onIdTokenChanged fires, the cookie is updated (via nookies.set) so that your server side “signed cookie” stays in sync with the real Firebase token.

Whenever the client makes a fetch request to your backend or external service, use a helper like:

import { getValidIdToken } from "next-firebase-auth-edge/lib/next/client";

export async function fetchWithAuth(serverIdToken: string, url: string, opts?: RequestInit) {
  const idToken = await getValidIdToken({
    serverIdToken,
    refreshTokenUrl: "/api/refresh-token",
  });
  return fetch(url, {
    ...opts,
    headers: {
      ...(opts?.headers ?? {}),
      Authorization: `Bearer ${idToken}`,
    },
  });
}

This ensures the token you attach is valid: getValidIdToken will call the refresh endpoint only if the server token is expired.

4. Server-side / SSR / server components: verifying the cookie

In your server components or getServerSideProps, you can inspect the signed cookie and verify it:

import nookies from "nookies";
import { verifyIdToken } from "next-firebase-auth-edge";
import { NextApiRequest, NextApiResponse, GetServerSidePropsContext } from "next";

export async function requireAuthSSR(ctx: GetServerSidePropsContext) {
  const cookies = nookies.get(ctx);
  const token = cookies.token;
  if (!token) {
    // redirect to login
    return { redirect: { destination: "/login", permanent: false } };
  }
  try {
    const decoded = await verifyIdToken(token);
    // user is authenticated
    return { props: { uid: decoded.uid, email: decoded.email } };
  } catch (err) {
    // invalid or expired token
    return { redirect: { destination: "/login", permanent: false } };
  }
}

Because middleware has already “renewed” the cookie when necessary (via the refresh endpoint), by the time this SSR code runs, the cookie is rotated and valid if possible.

Optional: rolling your own minimal setup (without a wrapper library)

If you don’t want to use next-firebase-auth-edge, you can craft your own flow. The core pieces you’ll need are:

  • Manual refresh: call the Firebase secure token endpoint to exchange refresh token for a new ID token.
  • Cookie signing/verification: when issuing cookies, use a secret to sign/verify JWTs in Node/Edge.
  • Middleware or request interceptors: detect expired cookies or responses, call refresh, retry request.

Example of a manual token refresh call (e.g. inside an API):

async function refreshIdToken(refreshToken: string, apiKey: string) {
  const resp = await fetch(
    `https://securetoken.googleapis.com/v1/token?key=${apiKey}`,
    {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        grant_type: "refresh_token",
        refresh_token: refreshToken,
      }),
    }
  );
  if (!resp.ok) {
    throw new Error("Failed to refresh token");
  }
  const j = await resp.json();
  // j.id_token is fresh ID token, j.refresh_token might be a rotated new refresh token
  return { idToken: j.id_token, newRefreshToken: j.refresh_token };
}

You’d need to store the refresh token securely (e.g. HTTP-only, signed cookie or server-side DB). During SSR or API-layer code, when a cookie’s ID token verification fails due to expiration, you can fall back to refresh and retry verifying on the new ID token.

Be cautious of race conditions (multiple simultaneous refresh attempts) and token rotation (refresh tokens may be single-use).

Best practices & gotchas

  • Always call getIdToken(true) when you want to force a refresh rather than risk stale.
  • Use onIdTokenChanged() as your canonical way to detect token refresh / sign-out events.
  • Don’t store the raw Firebase refresh token in an insecure location (e.g. localStorage). Use HTTP-only, signed cookies or server-side storage.
  • If you revoke a user’s refresh token (via Admin SDK), verify with verifyIdToken(token, checkRevoked = true) so that revoked tokens are invalidated.
  • In long-lived browser sessions, cookies issued long ago can expire. That’s why having a refresh endpoint is beneficial.
  • Be mindful of edge runtime limitations (no Node crypto) — that’s one reason libraries like next-firebase-auth-edge exist.
  • If your token contains large custom claims, consider splitting across multiple cookies (enableMultipleCookies option in the library) to avoid size limits.

Was this helpful?

Thanks for your feedback!
Alex is the resident editor and oversees all of the guides published. His past work and experience include Colorlib, Stack Diary, Hostvix, and working with a number of editorial publications. He has been wrangling code and publishing his findings about it since the early 2000s.

Leave a comment

Your email address will not be published. Required fields are marked *