Guide · Auth & Identity
MCP Tools for Firebase Auth — ID tokens, custom tokens, session cookies, Admin SDK verification
Three Firebase Auth behaviours catch developers building MCP auth integrations: verifyIdToken(token) does NOT check revocation by default — a user who logs out or has their account disabled will still pass verification until the token's 1-hour expiry unless you call verifyIdToken(token, true) with the checkRevoked flag, which adds a network round-trip to Firebase for every verification; custom tokens are one-time sign-in credentials, not bearer tokens — they are short-lived (1-hour) JWTs minted by the Admin SDK for a specific UID, used by the client to sign in via signInWithCustomToken() once and obtain a regular ID token; the custom token itself cannot be used to authenticate API requests and is discarded after sign-in; and Admin SDK initializeApp() must be called exactly once per Firebase project — calling it multiple times without a named app parameter throws FirebaseAppError: The default Firebase app already exists, and in serverless environments where the module is re-imported across requests, you must guard initialisation with a getApps().length === 0 check.
TL;DR
Verify ID token: await admin.auth().verifyIdToken(idToken) (no revocation check) or await admin.auth().verifyIdToken(idToken, true) (with revocation, network call). Session cookie: admin.auth().createSessionCookie(idToken, { expiresIn }), verify with admin.auth().verifySessionCookie(cookie). Custom token: server creates, client signs in once, then uses ID token. Init guard: if (!getApps().length) initializeApp(...).
ID token verification — revocation and latency trade-offs
Firebase Auth ID tokens are RS256 JWTs that expire after 1 hour. The Admin SDK's verifyIdToken() verifies the signature against Firebase's public keys (cached locally) but does not check whether the user's session has been revoked. For MCP servers handling sensitive operations, the checkRevoked parameter is critical for security.
import { initializeApp, getApps, cert } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';
// Initialisation guard — essential in Next.js / serverless environments
// where the module may be evaluated multiple times
if (!getApps().length) {
initializeApp({
credential: cert({
projectId: process.env.FIREBASE_PROJECT_ID!,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL!,
privateKey: process.env.FIREBASE_PRIVATE_KEY!.replace(/\\n/g, '\n'),
// IMPORTANT: the private key has literal '\n' in environment variables
// Replace '\n' string with actual newlines before passing to cert()
}),
});
}
const auth = getAuth();
// Fast path: verify signature only (no network call after initial JWKS fetch)
// Firebase caches public keys from https://www.googleapis.com/service_accounts/v1/jwk/...
// Cache TTL is set by the Cache-Control: max-age header (typically 6 hours)
async function verifyIdToken(idToken: string) {
try {
const decoded = await auth.verifyIdToken(idToken);
// decoded.uid — Firebase user UID
// decoded.email — user email (may be undefined for phone-auth users)
// decoded.email_verified — boolean
// decoded.firebase.sign_in_provider — 'google.com', 'password', 'phone', etc.
return decoded;
} catch (err) {
// auth/id-token-expired — token is past its 1-hour expiry
// auth/id-token-revoked — user signed out (only thrown when checkRevoked: true)
// auth/argument-error — malformed token
// auth/user-disabled — user account disabled (only thrown when checkRevoked: true)
throw new Error(`Token verification failed: ${err}`);
}
}
// Secure path: verify signature AND check revocation (network call to Firebase)
// Use this for sensitive MCP tools (write operations, admin actions, data deletion)
async function verifyIdTokenSecure(idToken: string) {
try {
const decoded = await auth.verifyIdToken(idToken, /* checkRevoked */ true);
return decoded;
} catch (err) {
const code = (err as { code?: string }).code;
if (code === 'auth/id-token-revoked') {
// Token was revoked — user signed out or admin revoked session
// Force re-authentication
throw new Error('Session revoked — please sign in again');
}
if (code === 'auth/user-disabled') {
// Account disabled in Firebase console
throw new Error('Account disabled');
}
throw err;
}
}
// Pattern: use fast verification for read-only tools, secure for write tools
async function handleMcpTool(
idToken: string,
toolName: string,
params: Record<string, unknown>
) {
const WRITE_TOOLS = new Set(['deleteData', 'updateProfile', 'sendMessage']);
const decoded = WRITE_TOOLS.has(toolName)
? await verifyIdTokenSecure(idToken)
: await verifyIdToken(idToken);
return executeTool(decoded.uid, toolName, params);
}
Firebase's public key cache uses the Cache-Control: max-age value from the key endpoint response — typically 6 hours. The Admin SDK manages this cache automatically. In edge deployments (Cloudflare Workers, Vercel Edge Runtime) where in-process caching doesn't persist across invocations, each cold-start fetches the public keys fresh. Mitigate by using session cookies (see below) which can be verified without a key fetch, or by pre-loading keys into a shared KV store.
Custom tokens — one-time sign-in model
Custom tokens let server-side code issue Firebase sign-in credentials for users whose identity was verified through a non-Firebase mechanism (e.g., your own backend auth, a third-party SSO). The key invariant: a custom token is used once to sign in, then discarded — it is not a bearer token for API requests.
import { getAuth } from 'firebase-admin/auth';
// Server-side: mint a custom token for a user authenticated via your own system
// uid must be a string that uniquely identifies the user in Firebase Auth
// If the user doesn't exist in Firebase Auth yet, Firebase creates them on first sign-in
async function issueCustomToken(
uid: string,
additionalClaims?: Record<string, string | boolean | number>
): Promise<string> {
const auth = getAuth();
// Custom token is a JWT signed by your service account's private key
// It expires after 1 hour and can only be used ONCE for sign-in
// additionalClaims are embedded in the token and appear in the ID token after sign-in
const customToken = await auth.createCustomToken(uid, additionalClaims);
// customToken format: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
// The uid you pass here becomes the user's uid in Firebase Auth
// If you pass '12345', the Firebase user's uid is '12345'
// If the uid doesn't exist in Firebase Auth, the user is created on sign-in
return customToken;
}
// CLIENT-SIDE: exchange the custom token for a Firebase ID token (one-time operation)
// import { getAuth as getClientAuth, signInWithCustomToken } from 'firebase/auth';
// const clientAuth = getClientAuth();
// const credential = await signInWithCustomToken(clientAuth, customToken);
// const idToken = await credential.user.getIdToken(); // use this for API requests
// The customToken itself is now consumed and cannot be reused
// Custom tokens are NOT for API authentication
// They flow: server → client SDK → Firebase Auth → ID token
// The ID token is what the client sends to your MCP server
// Use case: MCP server receives a bearer token from your own auth system,
// validates it, then issues a custom Firebase token so the client can access
// Firebase services (Firestore, Storage) directly with Firebase-native auth
async function exchangeYourTokenForFirebaseToken(
yourApiToken: string,
userId: string
): Promise<{ customToken: string }> {
// Validate your own token first
const user = await yourAuthSystem.validateToken(yourApiToken);
if (!user) throw new Error('Invalid token');
// Issue custom token with user's role as an additional claim
const customToken = await issueCustomToken(user.id, {
plan: user.plan, // 'free' | 'pro' | 'enterprise'
orgId: user.orgId, // appears as token.orgId in Firestore security rules
});
return { customToken };
}
// IMPORTANT: Additional claims in custom tokens appear as claims in the resulting ID token
// Firestore security rules can access them via request.auth.token.plan
// Maximum size of additional claims: 1000 bytes (JSON stringified)
The uid you pass to createCustomToken() is the Firebase UID — it's a string you control. Unlike Firebase's auto-generated UIDs (e.g., when users sign up via Google), custom token UIDs are your own identifiers. If a user already exists in Firebase Auth with that UID, sign-in via custom token updates their sign-in metadata but does not change their existing claims or linked providers. If the user doesn't exist, Firebase creates a new auth record with that UID on first sign-in.
Session cookies — long-lived server-managed sessions
Firebase Auth session cookies solve the ID token's 1-hour limitation for web applications. A session cookie is issued by the Admin SDK from a valid ID token and can last 5 minutes to 2 weeks. Unlike ID tokens, session cookies are managed by your server and can be revoked explicitly.
import { getAuth } from 'firebase-admin/auth';
// Create a session cookie from a valid ID token
// Call this after the user signs in on the client and sends their ID token to your server
async function createSessionCookie(
idToken: string,
expiresInMs: number = 7 * 24 * 60 * 60 * 1000 // 7 days
): Promise<string> {
const auth = getAuth();
// Minimum: 5 minutes; Maximum: 14 days (1209600000 ms)
if (expiresInMs < 5 * 60 * 1000 || expiresInMs > 14 * 24 * 60 * 60 * 1000) {
throw new Error('Session cookie duration must be between 5 minutes and 14 days');
}
// verifyIdToken is called internally by createSessionCookie —
// you don't need to verify the ID token separately before this call
const sessionCookie = await auth.createSessionCookie(idToken, { expiresIn: expiresInMs });
return sessionCookie;
// sessionCookie is a JWT — set it as an HttpOnly, Secure, SameSite=Strict cookie
}
// Verify a session cookie on each MCP tool invocation
async function verifySessionCookie(
sessionCookie: string,
checkRevoked = false
): Promise<import('firebase-admin/auth').DecodedIdToken> {
const auth = getAuth();
try {
// checkRevoked: true adds a network call to Firebase to check revocation
// Use it when revocation responsiveness matters (account takeover response, forced logout)
const decoded = await auth.verifySessionCookie(sessionCookie, checkRevoked);
return decoded;
} catch (err) {
const code = (err as { code?: string }).code;
if (code === 'auth/session-cookie-revoked') {
throw new Error('Session expired — please sign in again');
}
if (code === 'auth/session-cookie-expired') {
throw new Error('Session expired — please sign in again');
}
throw err;
}
}
// Revoke a session — invalidates all session cookies for this user
// Use when: user logs out, password changed, security incident
async function revokeUserSessions(uid: string): Promise<void> {
const auth = getAuth();
// revokeRefreshTokens revokes all refresh tokens and session cookies for the user
await auth.revokeRefreshTokens(uid);
// After this, all existing session cookies for this user will fail verifySessionCookie
// with auth/session-cookie-revoked (when checkRevoked: true)
}
// Session cookie pattern for Next.js MCP routes
// app/api/mcp/[tool]/route.ts
export async function POST(req: Request) {
const sessionCookie = req.headers.get('Cookie')
?.split(';')
.find(c => c.trim().startsWith('session='))
?.split('=')?.[1];
if (!sessionCookie) {
return Response.json({ error: 'Unauthenticated' }, { status: 401 });
}
const decoded = await verifySessionCookie(sessionCookie);
// decoded.uid, decoded.email, decoded.firebase.sign_in_provider, etc.
const { tool, params } = await req.json();
return Response.json(await executeTool(decoded.uid, tool, params));
}
Session cookies survive beyond the 1-hour ID token window, making them well-suited for MCP server contexts where users start long-running agent sessions and expect their authentication to persist. The Firebase Admin SDK verifies session cookies using the same public key infrastructure as ID tokens, so the initial JWKS fetch and caching apply equally. Session cookie revocation via revokeRefreshTokens() takes effect immediately for checkRevoked: true verification — without checkRevoked, revoked sessions continue to appear valid until the session cookie's exp claim passes.
Admin SDK initialisation — single app and multi-app patterns
Firebase Admin SDK's initializeApp() must be called before any Admin SDK operations, and only once per named app. In Next.js and serverless environments, modules are re-evaluated on cold starts but not on warm-start invocations — the guard pattern prevents double-initialisation errors.
import { initializeApp, getApps, getApp, cert, App } from 'firebase-admin/app';
import { getAuth, Auth } from 'firebase-admin/auth';
// Single-project pattern (most common)
function getAdminAuth(): Auth {
if (!getApps().length) {
initializeApp({
credential: cert({
projectId: process.env.FIREBASE_PROJECT_ID!,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL!,
privateKey: process.env.FIREBASE_PRIVATE_KEY!.replace(/\\n/g, '\n'),
}),
});
}
return getAuth();
}
// Multi-project pattern — when your MCP server integrates with multiple Firebase projects
// Each project needs a named app
const APP_NAMES = {
userFacing: 'user-facing',
analytics: 'analytics',
} as const;
function initFirebaseApps() {
const existingNames = getApps().map(app => app.name);
if (!existingNames.includes(APP_NAMES.userFacing)) {
initializeApp(
{
credential: cert({
projectId: process.env.FIREBASE_USER_PROJECT_ID!,
clientEmail: process.env.FIREBASE_USER_CLIENT_EMAIL!,
privateKey: process.env.FIREBASE_USER_PRIVATE_KEY!.replace(/\\n/g, '\n'),
}),
},
APP_NAMES.userFacing
);
}
if (!existingNames.includes(APP_NAMES.analytics)) {
initializeApp(
{
credential: cert({
projectId: process.env.FIREBASE_ANALYTICS_PROJECT_ID!,
clientEmail: process.env.FIREBASE_ANALYTICS_CLIENT_EMAIL!,
privateKey: process.env.FIREBASE_ANALYTICS_PRIVATE_KEY!.replace(/\\n/g, '\n'),
}),
},
APP_NAMES.analytics
);
}
}
// Retrieve auth for a specific named app
function getAuthForApp(appName: string): Auth {
initFirebaseApps();
const app = getApp(appName);
return getAuth(app);
}
// COMMON MISTAKE: getAuth() vs getAuth(app)
// getAuth() → auth for the DEFAULT app (the unnamed initializeApp call)
// getAuth(app) → auth for the NAMED app
// Mixing these in a multi-project setup causes tokens from one project
// to be verified against the other project's public keys — silent verification success
// with wrong uid values (Firebase projects share public key infrastructure but
// tokens carry project-specific audience claims that cross-project verification rejects)
// Verify that a token belongs to the expected project
async function verifyTokenForProject(idToken: string, projectId: string) {
const decoded = await getAuth().verifyIdToken(idToken);
// decoded.aud is the Firebase project ID — verify it matches expected
if (decoded.aud !== projectId) {
throw new Error(`Token issued for wrong project: ${decoded.aud}`);
}
return decoded;
}
In Google Cloud Functions and Cloud Run, the Admin SDK can initialise without explicit credentials when running inside GCP — initializeApp() with no arguments uses Application Default Credentials (ADC) from the service account attached to the function or Cloud Run service. This simplifies deployment but requires the service account to have the Firebase Auth Admin IAM role. In local development, set GOOGLE_APPLICATION_CREDENTIALS to the path of a service account key file to emulate this behaviour.
Multi-tenancy — Firebase Identity Platform tenant scoping
Firebase Auth multi-tenancy (available in Firebase Identity Platform, formerly GCP Identity Platform) allows a single Firebase project to host multiple tenant authentication silos. Tenant-scoped Auth operations use a separate Admin SDK interface and produce ID tokens with a firebase.tenant claim.
import { getAuth } from 'firebase-admin/auth';
const auth = getAuth();
const tenantManager = auth.tenantManager();
// Create a tenant-scoped auth instance for a specific tenant
function getTenantAuth(tenantId: string) {
return tenantManager.authForTenant(tenantId);
}
// Verify a tenant user's ID token
// IMPORTANT: must use the tenant-scoped auth instance, not the project-level auth
async function verifyTenantUserToken(idToken: string, tenantId: string) {
const tenantAuth = getTenantAuth(tenantId);
try {
const decoded = await tenantAuth.verifyIdToken(idToken);
// decoded.firebase.tenant — the tenant ID embedded in the token
// If token's tenant != tenantId, Firebase throws auth/id-token-expired
// (confusing error — actually an audience mismatch for the tenant)
return decoded;
} catch (err) {
throw new Error(`Tenant token verification failed for tenant ${tenantId}: ${err}`);
}
}
// Determine tenant from the token before verification
// Useful when your MCP server serves multiple tenants from the same endpoint
async function verifyMultiTenantToken(idToken: string): Promise<{
uid: string;
tenantId: string;
email?: string;
}> {
// Decode without verification to read the tenant claim
const [, payloadB64] = idToken.split('.');
const payload = JSON.parse(Buffer.from(payloadB64, 'base64').toString());
// firebase.tenant claim is present for tenant users; absent for project-level users
const tenantId: string | undefined = payload.firebase?.tenant;
let decoded;
if (tenantId) {
// Tenant user — verify with tenant-scoped auth
const allowedTenants = process.env.ALLOWED_TENANT_IDS?.split(',') ?? [];
if (!allowedTenants.includes(tenantId)) {
throw new Error(`Tenant not allowed: ${tenantId}`);
}
decoded = await getTenantAuth(tenantId).verifyIdToken(idToken, true);
} else {
// Project-level user (no tenant)
decoded = await auth.verifyIdToken(idToken, true);
}
return {
uid: decoded.uid,
tenantId: tenantId ?? 'root',
email: decoded.email,
};
}
Tenant IDs are stable strings (e.g., acme-corp-abcd1) assigned by Firebase when you create a tenant. They appear in ID tokens under firebase.tenant. Tokens issued by a tenant-scoped sign-in cannot be verified by the project-level auth instance — they must be verified by the tenant-specific authForTenant(tenantId) instance. Attempting to verify a tenant token with the project-level auth produces an error, which is the correct behaviour for enforcing tenant isolation.
Health probe — Firebase Auth reachability from MCP server
Firebase Auth's reliability is critical for MCP servers that verify ID tokens on every tool call. A Firebase Auth degradation surface as increased verifyIdToken() latency before manifesting as failures. Monitor both the JWKS key endpoint and a lightweight Admin SDK operation.
import { getAuth } from 'firebase-admin/auth';
async function checkFirebaseAuthHealth(): Promise<{
healthy: boolean;
jwksReachable: boolean;
adminSdkWorking: boolean;
latencyMs?: number;
error?: string;
}> {
const start = Date.now();
// Check 1: JWKS endpoint reachability (public keys for token verification)
// Firebase uses two JWKS endpoints depending on the key type:
// ID tokens: https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com
// Session cookies: https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys
let jwksReachable = false;
try {
const jwksRes = await fetch(
'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com',
{ signal: AbortSignal.timeout(5000) }
);
jwksReachable = jwksRes.ok;
} catch {
jwksReachable = false;
}
// Check 2: Admin SDK operation (validates service account credentials)
let adminSdkWorking = false;
try {
const auth = getAuth();
// listUsers with maxResults: 1 is a lightweight probe
await auth.listUsers(1);
adminSdkWorking = true;
} catch (err) {
return {
healthy: false,
jwksReachable,
adminSdkWorking: false,
error: `Admin SDK error: ${err}`,
};
}
const latencyMs = Date.now() - start;
const healthy = jwksReachable && adminSdkWorking;
return { healthy, jwksReachable, adminSdkWorking, latencyMs };
}
AliveMCP monitors Firebase Auth-integrated MCP servers by tracking JWKS endpoint response times and Admin SDK operation latency. A pattern where token verification latency increases while the JWKS endpoint remains reachable typically indicates Firebase Auth service degradation rather than a network issue — the Admin SDK retries internally, masking the problem until verification calls begin timing out.
Related guides
- MCP Tools for Clerk — session tokens, JWT templates, webhook verification
- MCP Tools for WorkOS — JWKS rotation, AuthKit, M2M API keys, Directory Sync
- MCP Tools for Keycloak — realm config, offline tokens, role claim mappers
- MCP Tools for NextAuth / Auth.js — session vs JWT callbacks, credentials provider
- MCP server authentication overview
- MCP server Supabase integration patterns