How to offer passwordless magic-link login with Firebase in Next

Passwordless magic-link login in Next.js with Firebase (and a clean server-side session)

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

Passwordless “magic links” let users authenticate by clicking a secure link delivered to their inbox—no passwords to forget, reset, or leak. In this guide, you will wire up email-link sign-in with Firebase Authentication, finish login on your server to mint an HttpOnly session cookie, and protect pages in a Next.js App Router app. We’ll deploy on DigitalOcean App Platform, so make sure you have a DigitalOcean account handy for the final step. We’ll use the current stable Next.js 15.x and the modular Firebase Web SDK.

You’ll create a Next.js app with:

  • A “Sign in” page that emails a magic link.
  • A verification page that completes sign-in, exchanges the Firebase ID token for a long-lived session cookie on your server, and redirects to a protected dashboard.
  • A server-side helper to read and verify the session cookie in Server Components.
  • Logout that clears the session.
  • Deployable configuration for DigitalOcean (env vars and authorized domains).

This flow keeps authentication state on the server with a signed cookie (up to 14 days), which is simpler to reason about than client-side tokens and works naturally with the App Router.

Prerequisites:

  • Node 18+ (Next.js 15 requires a modern Node runtime).
  • A Firebase project with Authentication enabled.
  • Email Link sign-in enabled under Build → Authentication → Sign-in method (Email/Password must be on, then enable Email link (passwordless)). Add your local and production domains to Authorized domains.

Project setup

Create a fresh Next.js project with the App Router:

npx create-next-app@latest passwordless-next --ts
cd passwordless-next
npm i firebase firebase-admin

Add environment variables in .env.local (you’ll mirror these in DigitalOcean later):

# Public Web SDK config (from Firebase console → Project settings → General)
NEXT_PUBLIC_FIREBASE_API_KEY=...
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=...
NEXT_PUBLIC_FIREBASE_PROJECT_ID=...
NEXT_PUBLIC_FIREBASE_APP_ID=...

# Used for redirects in actionCodeSettings (no trailing slash)
NEXT_PUBLIC_BASE_URL=http://localhost:3000

# Admin SDK credentials (service account JSON, as a single line)
FIREBASE_SERVICE_ACCOUNT={"type":"service_account", ... }

# Session cookie lifetime in days
SESSION_DAYS=14

In the Firebase console, set the email action redirect to ${NEXT_PUBLIC_BASE_URL}/auth/verify and add http://localhost:3000 to Authorized domains for local dev.

Initialize Firebase (client and admin)

Create lightweight init helpers.

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);

lib/firebase/admin.ts

import { cert, getApps, initializeApp, AppOptions } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';

const serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT as string);

const opts: AppOptions = { credential: cert(serviceAccount) };

export const adminApp = getApps().length ? getApps()[0] : initializeApp(opts);
export const adminAuth = getAuth(adminApp);

The Admin SDK will mint and verify session cookies on the server.

Build the sign-in page (send the magic link)

Create a client component to submit an email and send the link using Firebase’s sendSignInLinkToEmail. We’ll store the email in localStorage to complete same-device sign-in without asking again.

app/(auth)/signin/page.tsx

'use client';

import { useState } from 'react';
import { clientAuth } from '@/lib/firebase/client';
import { sendSignInLinkToEmail, ActionCodeSettings } from 'firebase/auth';

export default function SignInPage() {
  const [email, setEmail] = useState('');
  const [sent, setSent] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const actionCodeSettings: ActionCodeSettings = {
    url: `${process.env.NEXT_PUBLIC_BASE_URL}/auth/verify`,
    handleCodeInApp: true,
  };

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError(null);
    try {
      await sendSignInLinkToEmail(clientAuth, email, actionCodeSettings);
      window.localStorage.setItem('emailForSignIn', email);
      setSent(true);
    } catch (err: any) {
      setError(err?.message ?? 'Failed to send link.');
    }
  }

  if (sent) {
    return (
      <main className="mx-auto max-w-md p-6">
        <h1 className="text-xl font-semibold">Check your email</h1>
        <p>We sent a sign-in link to <strong>{email}</strong>. Open it to finish signing in.</p>
      </main>
    );
  }

  return (
    <main className="mx-auto max-w-md p-6">
      <h1 className="text-xl font-semibold mb-4">Sign in</h1>
      <form onSubmit={onSubmit} className="space-y-4">
        <input
          type="email"
          required
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          placeholder="you@example.com"
          className="w-full rounded border p-2"
          autoComplete="email"
        />
        <button className="rounded bg-black px-4 py-2 text-white">Email me a magic link</button>
      </form>
      {error && <p className="mt-4 text-red-600">{error}</p>}
    </main>
  );
}

The handleCodeInApp option tells Firebase Auth to generate a sign-in link that your app will handle client-side.

Finish sign-in and set an HttpOnly session cookie

On the verification page, complete the Firebase sign-in, then POST the ID token to a Route Handler that mints a long-lived session cookie via the Admin SDK. We use cookies() in the handler to set the HttpOnly cookie for server-side auth.

app/auth/verify/page.tsx

'use client';

import { useEffect, useState } from 'react';
import { clientAuth } from '@/lib/firebase/client';
import { isSignInWithEmailLink, signInWithEmailLink } from 'firebase/auth';

export default function VerifyPage() {
  const [status, setStatus] = useState('Verifying…');

  useEffect(() => {
    async function run() {
      try {
        const href = window.location.href;
        if (!isSignInWithEmailLink(clientAuth, href)) {
          setStatus('Invalid or expired link.');
          return;
        }
        let email = window.localStorage.getItem('emailForSignIn');
        if (!email) {
          email = window.prompt('Confirm your email to finish signing in') ?? '';
        }
        const cred = await signInWithEmailLink(clientAuth, email, href);
        const idToken = await cred.user.getIdToken(/* forceRefresh */ true);

        const res = await fetch('/api/session', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ idToken }),
        });
        if (!res.ok) throw new Error('Session creation failed');
        window.localStorage.removeItem('emailForSignIn');
        window.location.assign('/dashboard');
      } catch (e: any) {
        console.error(e);
        setStatus('Could not verify the link.');
      }
    }
    run();
  }, []);

  return (
    <main className="mx-auto max-w-md p-6">
      <h1 className="text-xl font-semibold">Finishing sign-in</h1>
      <p>{status}</p>
    </main>
  );
}

app/api/session/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { adminAuth } from '@/lib/firebase/admin';
import { cookies } from 'next/headers';

const sessionDays = Number(process.env.SESSION_DAYS ?? '14');
const maxAge = sessionDays * 24 * 60 * 60 * 1000;

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

  // Optional: check if user is verified or add custom checks here

  const sessionCookie = await adminAuth.createSessionCookie(idToken, { expiresIn: maxAge });
  const res = NextResponse.json({ status: 'ok' });
  const jar = await cookies();
  jar.set({
    name: 'session',
    value: sessionCookie,
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/',
    maxAge: Math.floor(maxAge / 1000),
  });
  return res;
}

export async function DELETE() {
  const jar = await cookies();
  const session = jar.get('session')?.value;
  if (session) {
    try {
      const decoded = await adminAuth.verifySessionCookie(session, true);
      await adminAuth.revokeRefreshTokens(decoded.sub);
    } catch {}
  }
  jar.set('session', '', { httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 0 });
  return NextResponse.json({ status: 'signed_out' });
}

Firebase Admin’s createSessionCookie issues a signed cookie your server can trust; verifySessionCookie reads it later without hitting the client SDK. Revoking refresh tokens on logout is a defense-in-depth measure.

Read the user on the server (protect pages)

Create a small helper to read the cookie and return the Firebase user claims. Use it inside Server Components to gate UI, and in Route Handlers for APIs.

lib/auth.ts

import { cookies } from 'next/headers';
import { adminAuth } from './firebase/admin';

export type SessionUser =
  | (Awaited<ReturnType<typeof adminAuth.verifySessionCookie>> & { email?: string })
  | null;

export async function getSessionUser(): Promise<SessionUser> {
  const jar = await cookies();
  const token = jar.get('session')?.value;
  if (!token) return null;
  try {
    const decoded = await adminAuth.verifySessionCookie(token, true);
    return decoded as any;
  } catch {
    return null;
  }
}

app/dashboard/page.tsx

import { redirect } from 'next/navigation';
import { getSessionUser } from '@/lib/auth';

export default async function Dashboard() {
  const user = await getSessionUser();
  if (!user) redirect('/(auth)/signin');

  return (
    <main className="mx-auto max-w-2xl p-6">
      <h1 className="text-2xl font-semibold">Welcome</h1>
      <p>You are signed in as <strong>{user.email ?? user.uid}</strong>.</p>
      <form action="/api/session" method="post" className="mt-6">
        <button
          formAction="/api/session"
          formMethod="delete"
          className="rounded bg-gray-900 px-4 py-2 text-white"
        >
          Sign out
        </button>
      </form>
    </main>
  );
}

The cookies() API is the supported way to read/write cookies in Server Components and Route Handlers in the App Router.

Local testing

  • Start the app with npm run dev, visit / (auth)/signin, and send yourself a link.
  • Open the email link on the same device to avoid re-prompting for your address; if you do open on a different device, the verify page will ask you to confirm the email.
  • After login, the session cookie appears in the browser (HttpOnly/secure) and /dashboard renders server-side.

Security notes

  • Always set Secure, HttpOnly, and SameSite=Lax (or Strict) on the session cookie.
  • Prefer server-side session cookies to long-lived client tokens; you can set lifetimes up to 14 days and verify or revoke on the server.
  • Avoid email enumeration in errors. Respond with generic messages on both “send link” and “verify” steps.
  • Rate-limit the POST to /api/session. Consider adding bot protection on the email form.
  • Add production domains to Firebase Authorized domains (your DigitalOcean default domain and any custom domains).

Deploy to DigitalOcean App Platform

Push your code to a Git repo. In App Platform, create an app from the repository and choose the Next.js preset. Set the environment variables from .env.local (mark secrets as “Encrypted”). The platform will build with next build and serve with next start by default. After the first deploy, add the assigned ondigitalocean.app domain and any custom domains to Firebase Authorized domains and update NEXT_PUBLIC_BASE_URL for production. Redeploy to pick up new env values.

Troubleshooting

  • “Operation not allowed.” Ensure Email/Password is enabled (required for email link) and Email Link sign-in is enabled.
  • Verify page says “invalid link.” Links expire or are single-use; resend the link. Also check the redirect URL matches actionCodeSettings.url.
  • Stuck signed in/out across tabs. Remember: with server-side cookies, the source of truth is the session cookie. Clear it via the DELETE handler or your browser’s cookie tools.

Why this structure (and what to adapt)

You could skip server cookies and keep the client logged in via the Web SDK alone, but you’d re-implement auth checks in client components and wire up token refresh on the client. By minting a session cookie at verification time, Server Components can trust cookies() for identity, and your layouts, loaders, and API routes can act on req alone. Next.js 15 App Router, Route Handlers, and the cookies API make this pattern concise and ergonomic.

Next steps

  • Add role-based authorization by attaching custom claims and reading them from verifySessionCookie results.
  • Customize Firebase email templates and domain branding for better deliverability.
  • Consider Turbopack builds in CI for faster deploys as you iterate.

With these pieces in place, you have a production-friendly passwordless flow: minimal friction for users, server-side sessions for your App Router pages, and a deployment target ready to scale on DigitalOcean.

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 *