Most teams reach for Firebase Authentication to offload sign-in and identity. The client SDK issues short-lived ID tokens, but your server still needs to verify them before doing anything sensitive. In a Next.js app with Route Handlers, the safest pattern is: obtain an ID token on the client, send it on each request, and verify it on the server with the Firebase Admin SDK. This guide walks you through an end-to-end setup with current Next.js and Firebase. You will deploy to a standard Node.js environment. If you plan to host on DigitalOcean App Platform, create a DigitalOcean account first so you can add environment variables during setup.
What you will build:
You will initialize a singleton Firebase Admin instance, create a typed Route Handler that verifies Authorization: Bearer <ID_TOKEN>
, and return data only for authenticated users. You will also learn how to avoid the Edge runtime for these routes, handle common verification errors, and optionally check for revoked tokens to enforce logout across devices.
Requirements and assumptions
Use Next.js 15 or newer, which ships stable App Router Route Handlers. Your secure routes must run on the Node.js runtime because Firebase Admin depends on Node core modules that are not available on the Edge runtime. This is true across providers that implement Edge isolation. If you need middleware level checks at the edge, use lightweight header presence checks only and delegate full verification to a Node handler.
1) Install packages
Install the Admin SDK in your project.
npm install firebase-admin
npm install --save-dev server-only
server-only
is a tiny guard that prevents accidental client bundles from importing server code.
2) Configure credentials safely
Prefer Workload Identity on cloud platforms. If you use a service account key, store it as environment variables. Many platforms require escaping newlines in PRIVATE_KEY
, so normalize the value at runtime as shown below. Never commit keys to your repository. To deploy on DigitalOcean App Platform, add these as “Environment Variables” in your component settings before your first deploy.
FIREBASE_PROJECT_ID
FIREBASE_CLIENT_EMAIL
FIREBASE_PRIVATE_KEY
(keep the literal\n
newlines or paste exact multiline text)
3) Create a singleton Admin SDK initializer
Next.js may instantiate modules multiple times during development or across serverless invocations. Create a module that exports a single Admin app and Auth instance. This prevents “already exists” errors and avoids wasted connections.
// app/lib/firebase-admin.ts
import 'server-only';
import { getApps, initializeApp, cert, getApp } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';
function getPrivateKey() {
const key = process.env.FIREBASE_PRIVATE_KEY;
if (!key) throw new Error('FIREBASE_PRIVATE_KEY is not set');
// Support both plain multiline and \n-escaped formats
return key.replace(/\\n/g, '\n');
}
export function adminApp() {
if (getApps().length) return getApp();
return initializeApp({
credential: cert({
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: getPrivateKey(),
}),
});
}
export function adminAuth() {
return getAuth(adminApp());
}
The getApps()
check ensures a single initialized app. This is the recommended pattern for serverless and dev hot reload.
4) Add a typed token extractor
Clients will send an ID token in the Authorization
header. Build a small utility that parses it and returns clear errors.
// app/lib/auth-headers.ts
export type Bearer = { scheme: 'Bearer'; token: string };
export function parseAuthorization(header: string | null): Bearer {
if (!header) throw new Error('Missing Authorization header');
const [scheme, token] = header.split(' ');
if (scheme !== 'Bearer' || !token) throw new Error('Malformed Authorization header');
return { scheme: 'Bearer', token };
}
5) Implement a secure Route Handler
Create a Route Handler in the App Router and force the Node runtime. Verify the ID token with the Admin SDK, handle expected failures, and return JSON.
// app/api/secure/route.ts
import { NextResponse } from 'next/server';
import { adminAuth } from '@/app/lib/firebase-admin';
import { parseAuthorization } from '@/app/lib/auth-headers';
export const runtime = 'nodejs'; // Admin SDK requires Node APIs
type AuthedResponse = {
uid: string;
email?: string;
customClaims?: Record<string, unknown>;
};
export async function GET(request: Request) {
try {
const { token } = parseAuthorization(request.headers.get('authorization'));
// Verify signature, audience, issuer, expiry
const decoded = await adminAuth().verifyIdToken(token);
// Optionally load custom claims or user profile here
const result: AuthedResponse = {
uid: decoded.uid,
email: decoded.email,
customClaims: decoded,
};
return NextResponse.json(result, { status: 200 });
} catch (err: unknown) {
// Map common Firebase Auth errors to HTTP
const message = err instanceof Error ? err.message : 'Unauthorized';
const code = message.includes('expired') ? 401 : 401;
return NextResponse.json({ error: 'Unauthorized', detail: message }, { status: code });
}
}
verifyIdToken
validates the token issuer, audience, signature with Google public keys, and expiry. If verification fails, respond with 401 and a stable error body.
6) Enforce global sign-out with token revocation checks
If you want sign-out to invalidate previously issued tokens immediately, pass checkRevoked: true
. This adds a revocation lookup and is suitable for admin panels or high-risk actions. On failure, prompt the client to refresh credentials or reauthenticate.
// app/api/secure/route.ts (variant)
const decoded = await adminAuth().verifyIdToken(token, true);
// If you reach this point, the token is valid and not revoked
Revocation checks require an extra call and can add latency. Use them selectively on routes that change data or access sensitive information.
7) Client: send the ID token on each request
On the client, after authentication with the Firebase web SDK, request the current ID token and send it in the Authorization
header. Refresh on 401.
// app/(client)/useAuthedFetch.ts
import { getAuth } from 'firebase/auth';
export async function authedFetch(input: RequestInfo, init: RequestInit = {}) {
const auth = getAuth();
const user = auth.currentUser;
const idToken = user ? await user.getIdToken(/* forceRefresh? */ false) : null;
const headers = new Headers(init.headers);
if (idToken) headers.set('Authorization', `Bearer ${idToken}`);
const res = await fetch(input, { ...init, headers });
if (res.status === 401 && user) {
// Token may be expired or revoked; attempt a refresh once
const fresh = await user.getIdToken(true);
headers.set('Authorization', `Bearer ${fresh}`);
return fetch(input, { ...init, headers });
}
return res;
}
Next.js will bundle this only for the client tree. Your server code that imports firebase-admin
is isolated by server-only
.
8) Common pitfalls and how to avoid them
Edge runtime errors. If you see errors about fs
, os
, or other Node modules, the route is running on the Edge runtime. Export runtime = 'nodejs'
from the handler. Do not import firebase-admin
from middleware. As of recent Next.js releases, Node middleware exists, but prefer verification inside Node Route Handlers where you control dependencies and error handling. (Next.js)
Multiple initializations. Reuse the app with getApps()
and a lazy initializer, as shown earlier. This prevents “The default Firebase app already exists” errors and keeps memory pressure low across cold starts.
Clock skew and expiry. ID tokens are short-lived. Expect 401 for expired tokens and request a fresh token on the client as shown. verifyIdToken
handles signature rotation automatically by fetching Google public keys.
Custom claims and authorization. Use custom claims on the user to drive authorization decisions and include a quick claim snapshot in your response. For changes to take effect, ask the client to refresh the ID token.
9) Optional: route-level guard helper
For many APIs, you will reuse the same verification block. Extract a guard that returns the decoded token or a NextResponse
error.
// app/lib/with-auth.ts
import { NextResponse } from 'next/server';
import { adminAuth } from '@/app/lib/firebase-admin';
import { parseAuthorization } from '@/app/lib/auth-headers';
import type { DecodedIdToken } from 'firebase-admin/auth';
export async function requireUser(
request: Request,
opts: { checkRevoked?: boolean } = {}
): Promise<{ token: DecodedIdToken } | { error: NextResponse }> {
try {
const { token } = parseAuthorization(request.headers.get('authorization'));
const decoded = await adminAuth().verifyIdToken(token, !!opts.checkRevoked);
return { token: decoded };
} catch (e) {
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
}
}
Use it inside handlers to keep code short and consistent.
// app/api/orders/route.ts
import { NextResponse } from 'next/server';
import { requireUser } from '@/app/lib/with-auth';
export const runtime = 'nodejs';
export async function POST(request: Request) {
const result = await requireUser(request, { checkRevoked: true });
if ('error' in result) return result.error;
const { token } = result;
// Example authorization check
if (token.admin !== true) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Continue with work that needs a verified user
return NextResponse.json({ ok: true });
}
10) Local and deployment notes
Local development. Use a .env.local
file and restart the dev server after adding secrets. Never expose Admin credentials with NEXT_PUBLIC_
prefixes.
Deployment. For DigitalOcean App Platform, set environment variables on the app component and select a Node.js runtime. For platforms that support file mounts, you may supply GOOGLE_APPLICATION_CREDENTIALS
pointing to a mounted JSON file. For most teams, environment variables are simpler.
HTTP testing. You can smoke-test with curl
by pasting a token from your browser after signing in. Replace <TOKEN>
below.
curl -i http://localhost:3000/api/secure \
-H "Authorization: Bearer <TOKEN>"
A 200 response returns your Firebase uid
. A 401 response indicates a missing, malformed, expired, or revoked token.
11) Production checklist
- Make every handler that imports
firebase-admin
exportruntime = 'nodejs'
. - Keep a single Admin app per process using the initializer shown earlier.
- Decide where to use
checkRevoked: true
to enforce sign-out for sensitive routes. - Do not use Admin SDK in Edge middleware. Route to a Node handler for verification.
- Keep keys out of git and normalize newline format for
PRIVATE_KEY
.
Where this leaves you
You now have a stable pattern for secure token verification in Next.js Route Handlers using the Firebase Admin SDK. The approach scales across serverless and traditional Node deployments, plays well with short-lived ID tokens, and supports immediate revocation when needed. Start with a single authenticated route, deploy it to your Node runtime on DigitalOcean or your preferred host, and extend the guard to the rest of your API as you add authorization rules.
Appendix: Reference types
If you want stronger typing on the decoded token and responses, you can import DecodedIdToken
from the Admin SDK and define your own claim map.
// app/types/auth.ts
import type { DecodedIdToken } from 'firebase-admin/auth';
export type Claims = DecodedIdToken & {
role?: 'user' | 'editor' | 'admin';
};
export type ApiError = { error: string; detail?: string };
This helps keep server code self-documenting as your authorization model grows.