How to implement email/password sign-in with Firebase in Next.js App Router

Implementing email/password sign-in with Firebase in Next.js (App Router)

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

Shipping a “basic” sign-in flow often takes longer than it should: you want email/password for broad accessibility, client state that doesn’t flicker, server protection for private routes, and a clean deploy story. In this guide you’ll wire up Firebase Authentication to a Next.js App Router app using current patterns: client-side sign in/out, HTTP-only session cookies, middleware gating, and server verification. You can run locally with the Firebase Emulator, and deploy to your platform of choice; if you plan to host on DigitalOcean App Platform later, make sure you have a DigitalOcean account ready so you can add your environment variables without breaking stride.

What you’ll build: a Next.js 15 app with /login and /signup pages, a protected /dashboard, and server-verified sessions based on Firebase Auth. We’ll use the Firebase Web SDK on the client and the Admin SDK in Node-runtime route handlers for session cookies. Next.js 15 is stable and fully App Router–native, so we’ll target that.

Prerequisites

  • Node 18+ (Node 20 recommended), pnpm/npm, and a Firebase project with Email/Password sign-in enabled. In the Firebase console: Build → Authentication → Sign-in method → Email/Password → Enable. (Firebase)
  • Locally: optional but useful — install the Firebase CLI and enable the Authentication Emulator for safe testing. (Firebase)

1) Create the Next.js app and install Firebase packages

# New project with the App Router (Next 15+)
npx create-next-app@latest next-firebase-auth
cd next-firebase-auth

# Dependencies
npm i firebase
npm i firebase-admin
npm i cookies       # tiny helper for parsing/setting cookies in handlers

# Dev-only (optional): Firebase Emulator
npm i -D firebase-tools

2) Configure Firebase (client and admin)

Create Firebase apps on both sides. The client app handles sign in/out. The admin app (server-side) exchanges ID tokens for session cookies and verifies them on requests. Firebase officially recommends verifyIdToken on the server and createSessionCookie for web sessions.

Environment variables (create .env.local):

# Web SDK
NEXT_PUBLIC_FIREBASE_API_KEY=...
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=...
NEXT_PUBLIC_FIREBASE_PROJECT_ID=...
NEXT_PUBLIC_FIREBASE_APP_ID=...

# Admin SDK (Service Account)
FIREBASE_PROJECT_ID=...
FIREBASE_CLIENT_EMAIL=...
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"

# Session cookie settings
SESSION_COOKIE_NAME=__session
SESSION_COOKIE_MAX_DAYS=14

Tip: When using the emulator, do not set production secrets; the Auth emulator accepts unsigned tokens with a safety guard for local dev.

Create lib/firebase/client.ts:

// lib/firebase/client.ts
import { initializeApp, getApps } from "firebase/app";
import { getAuth, browserLocalPersistence, setPersistence } 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 auth = getAuth(clientApp);

// Persist across tabs & reloads
setPersistence(auth, browserLocalPersistence);

Create lib/firebase/admin.ts:

// lib/firebase/admin.ts
import { getApps, initializeApp, cert, App } from "firebase-admin/app";
import { getAuth } from "firebase-admin/auth";

let adminApp: App;

if (!getApps().length) {
  adminApp = initializeApp({
    credential: cert({
      projectId: process.env.FIREBASE_PROJECT_ID,
      clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
      // Replace escaped newlines
      privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, "\n"),
    }),
  });
} else {
  adminApp = getApps()[0]!;
}

export const adminAuth = getAuth(adminApp);

3) Sign up, sign in, sign out (client components)

Enable Email/Password in Firebase, then wire the form to createUserWithEmailAndPassword and signInWithEmailAndPassword. These are the canonical Web SDK entry points for password auth.

app/signup/page.tsx:

"use client";

import { FormEvent, useState } from "react";
import { auth } from "@/lib/firebase/client";
import { createUserWithEmailAndPassword } from "firebase/auth";

export default function SignUpPage() {
  const [error, setError] = useState<string | null>(null);

  async function onSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const form = e.currentTarget;
    const email = (form.elements.namedItem("email") as HTMLInputElement).value;
    const password = (form.elements.namedItem("password") as HTMLInputElement).value;

    try {
      const cred = await createUserWithEmailAndPassword(auth, email, password);
      // Exchange ID token for a session cookie
      const idToken = await cred.user.getIdToken();
      await fetch("/api/session", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ idToken }),
      });
      window.location.assign("/dashboard");
    } catch (err: any) {
      setError(err.message ?? "Sign up failed");
    }
  }

  return (
    <main className="max-w-md mx-auto py-12">
      <h1 className="text-2xl font-semibold">Create your account</h1>
      <form onSubmit={onSubmit} className="mt-6 space-y-4">
        <input name="email" type="email" placeholder="you@example.com" className="w-full border p-2 rounded" required />
        <input name="password" type="password" placeholder="••••••••" className="w-full border p-2 rounded" required />
        <button className="w-full bg-black text-white p-2 rounded">Create account</button>
      </form>
      {error && <p className="mt-4 text-red-600">{error}</p>}
    </main>
  );
}

app/login/page.tsx:

"use client";

import { FormEvent, useState } from "react";
import { auth } from "@/lib/firebase/client";
import { signInWithEmailAndPassword, signOut } from "firebase/auth";

export default function LoginPage() {
  const [error, setError] = useState<string | null>(null);

  async function onSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const form = e.currentTarget;
    const email = (form.elements.namedItem("email") as HTMLInputElement).value;
    const password = (form.elements.namedItem("password") as HTMLInputElement).value;

    try {
      const cred = await signInWithEmailAndPassword(auth, email, password);
      const idToken = await cred.user.getIdToken();
      await fetch("/api/session", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ idToken }),
      });
      window.location.assign("/dashboard");
    } catch (err: any) {
      setError(err.message ?? "Sign in failed");
    }
  }

  async function onSignOut() {
    await fetch("/api/session", { method: "DELETE" });
    await signOut(auth);
    window.location.assign("/login");
  }

  return (
    <main className="max-w-md mx-auto py-12">
      <h1 className="text-2xl font-semibold">Welcome back</h1>
      <form onSubmit={onSubmit} className="mt-6 space-y-4">
        <input name="email" type="email" placeholder="you@example.com" className="w-full border p-2 rounded" required />
        <input name="password" type="password" placeholder="••••••••" className="w-full border p-2 rounded" required />
        <button className="w-full bg-black text-white p-2 rounded">Sign in</button>
      </form>
      <button className="mt-4 underline" onClick={onSignOut}>Sign out</button>
      {error && <p className="mt-4 text-red-600">{error}</p>}
    </main>
  );
}

4) Exchange ID tokens for HTTP-only session cookies (route handler)

The client SDK issues a short-lived ID token. For SSR and middleware gating, the Admin SDK should exchange that for a session cookie (up to 14 days) and verify it on the server. We’ll implement this in a Node runtime route handler.

app/api/session/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { adminAuth } from "@/lib/firebase/admin";
import { serialize } from "cookie";

export const runtime = "nodejs"; // Admin SDK needs Node runtime

const name = process.env.SESSION_COOKIE_NAME || "__session";

export async function POST(request: NextRequest) {
  const { idToken } = await request.json();
  if (!idToken) {
    return NextResponse.json({ error: "Missing idToken" }, { status: 400 });
  }

  // 14d max per Firebase docs; configurable
  const days = Number(process.env.SESSION_COOKIE_MAX_DAYS ?? 14);
  const expiresIn = days * 24 * 60 * 60 * 1000;

  const sessionCookie = await adminAuth.createSessionCookie(idToken, { expiresIn });
  const cookie = serialize(name, sessionCookie, {
    httpOnly: true,
    secure: true,
    path: "/",
    sameSite: "lax",
    maxAge: expiresIn / 1000,
  });

  const res = NextResponse.json({ status: "ok" });
  res.headers.set("Set-Cookie", cookie);
  return res;
}

export async function DELETE() {
  // Clear cookie; client will also call auth.signOut()
  const cookie = serialize(name, "", {
    httpOnly: true,
    secure: true,
    path: "/",
    sameSite: "lax",
    maxAge: 0,
  });
  const res = NextResponse.json({ status: "signed_out" });
  res.headers.set("Set-Cookie", cookie);
  return res;
}

5) Gate pages with middleware (presence check) and verify on the server (decoding)

Middleware runs before route handling. Because Edge runtime can’t use the Admin SDK, use middleware to check for the presence of the session cookie and redirect unauthenticated users. Perform cryptographic verification in server components or route handlers that run in Node runtime. This pattern keeps navigation snappy while staying correct. (If you host on Firebase App Hosting, the Web SDK’s initializeServerApp can also help in middleware; we’ll stick to the portable cookie-presence strategy here.)

middleware.ts:

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const name = process.env.SESSION_COOKIE_NAME || "__session";

// Runs in Edge by default
export function middleware(req: NextRequest) {
  const hasSession = req.cookies.get(name)?.value;
  const { pathname } = req.nextUrl;

  const isAuthPage = pathname.startsWith("/login") || pathname.startsWith("/signup");
  if (!hasSession && pathname.startsWith("/dashboard")) {
    const url = new URL("/login", req.url);
    url.searchParams.set("next", pathname);
    return NextResponse.redirect(url);
  }
  if (hasSession && isAuthPage) {
    return NextResponse.redirect(new URL("/dashboard", req.url));
  }
  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*", "/login", "/signup"],
};

lib/auth/server-verify.ts:

// lib/auth/server-verify.ts
import { cookies } from "next/headers";
import { adminAuth } from "@/lib/firebase/admin";

const name = process.env.SESSION_COOKIE_NAME || "__session";

export type ServerUser = { uid: string; email?: string | null; claims: Record<string, unknown> };

export async function getServerUser(): Promise<ServerUser | null> {
  const cookieStore = await cookies();
  const session = cookieStore.get(name)?.value;
  if (!session) return null;

  try {
    const decoded = await adminAuth.verifySessionCookie(session, true); // checks revocation
    const { uid, email, ...rest } = decoded as any;
    return { uid, email, claims: rest };
  } catch {
    return null;
  }
}

app/dashboard/page.tsx:

import { getServerUser } from "@/lib/auth/server-verify";

export const runtime = "nodejs"; // ensure Admin SDK works here

export default async function DashboardPage() {
  const user = await getServerUser();
  if (!user) {
    // A server-side guard in case someone bypasses middleware
    return (
      <main className="max-w-xl mx-auto py-12">
        <h1 className="text-2xl font-semibold">Not signed in</h1>
        <p>Please sign in to continue.</p>
      </main>
    );
  }

  return (
    <main className="max-w-xl mx-auto py-12">
      <h1 className="text-2xl font-semibold">Dashboard</h1>
      <p className="mt-4">Signed in as <strong>{user.email ?? user.uid}</strong></p>
    </main>
  );
}

Why this split? Firebase recommends verifying ID tokens or session cookies server-side with the Admin SDK. Middleware runs at the edge by default and can’t import Node modules, so we use it for fast presence checks and rely on Node-runtime verification where it matters. If you are all-in on Firebase App Hosting, their newer FirebaseServerApp pattern also enables middleware-level checks with an ID token propagated by a Service Worker.

6) Password reset and email verification (optional but expected)

Add a simple action for password reset with sendPasswordResetEmail. Trigger email verification after sign-up with sendEmailVerification. Both are first-class in the Web SDK.

lib/auth/client-actions.ts:

"use client";

import { auth } from "@/lib/firebase/client";
import { sendPasswordResetEmail, sendEmailVerification } from "firebase/auth";

export async function requestPasswordReset(email: string) {
  await sendPasswordResetEmail(auth, email);
}

export async function sendVerifyEmail() {
  if (!auth.currentUser) throw new Error("Not signed in");
  await sendEmailVerification(auth.currentUser);
}

(These flows use Firebase-hosted action handlers by default; you can customize them later.)

7) Local development with the Firebase Emulator (optional)

For rapid iteration, run the Auth Emulator, which issues unsigned tokens acceptable to the Admin SDK when FIREBASE_AUTH_EMULATOR_HOST is set. Do not enable this in production.

# in one terminal
npx firebase emulators:start --only auth

# in another terminal
npm run dev

In lib/firebase/client.ts, detect the emulator during dev:

import { connectAuthEmulator } from "firebase/auth";

if (process.env.NEXT_PUBLIC_USE_EMULATOR === "true") {
  connectAuthEmulator(auth, "http://127.0.0.1:9099");
}

8) Deployment notes (Next.js 15 + environment)

  • Next.js 15 ships stable App Router improvements and modern TS ergonomics; your code above runs as-is. Keep your export const runtime = 'nodejs' where the Admin SDK is used.
  • In your host (including DigitalOcean App Platform), create the same environment variables you used locally. For the private key, preserve newline characters by storing it with \n escapes and replacing them at runtime (as shown in admin.ts).
  • If you later deploy on Firebase App Hosting, you can optionally adopt initializeServerApp plus a Service Worker to propagate ID tokens into middleware. It’s a good fit for edge-heavy setups with tight redirects.

9) Testing the flow end-to-end

  1. Visit /signup, create a user, and confirm you land on /dashboard.
  2. Refresh the page; you should remain signed in thanks to the HTTP-only session cookie.
  3. Click Sign out; verify the cookie clears and middleware sends you to /login.
  4. (Optional) Turn on email verification in the Firebase console and call sendVerifyEmail() after sign-up to require verified emails for access.

10) Troubleshooting

  • “Admin SDK not working in middleware”: expected — middleware is Edge by default and Node built-ins aren’t available. Use the presence-check middleware + Node-runtime verification as above, or adopt Firebase’s server app approach for middleware when hosting on Firebase App Hosting.
  • “Where do I verify tokens?”: use adminAuth.verifySessionCookie(cookie, /* checkRevoked */ true) or, if you skip session cookies, adminAuth.verifyIdToken(idToken) on requests that need protection. The Admin docs cover both.
  • “Why session cookies, not just ID tokens?”: ID tokens expire hourly and are awkward to forward on every request. Session cookies are long-lived, HTTP-only, and play nicely with SSR. Firebase’s server-side session guidance reflects this.

What you have now

  • A Next.js App Router project on the current major (15)
  • Email/password auth with robust server verification
  • Middleware-gated routes without layout flicker
  • A deployment-ready environment strategy (including DigitalOcean)

If you later add roles, set custom claims with the Admin SDK and read them from verifySessionCookie in getServerUser(). The cookie approach you implemented carries those claims across requests, and the middleware can redirect based on route patterns (for example, /admin only if claims.admin === true).

Reference snippets you might reuse

Sign in & exchange token (client):

const cred = await signInWithEmailAndPassword(auth, email, password);
const idToken = await cred.user.getIdToken();
await fetch("/api/session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ idToken }) });

Verify on server:

const decoded = await adminAuth.verifySessionCookie(sessionCookie, true);

Presence-check middleware:

export function middleware(req: NextRequest) {
  return req.cookies.get("__session") ? NextResponse.next() : NextResponse.redirect(new URL("/login", req.url));
}

With this structure you can add social providers later without changing your session model, and you can deploy to DigitalOcean (or anywhere) by copying your environment variables and keeping Node-runtime handlers wherever the Admin SDK is imported.

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 *