Authenticate with Firebase Auth
A Firebase client SDK hands your application an ID token: an RS256 JWT whose
iss is https://securetoken.google.com/<project-id>, whose aud is the bare
project id, and whose sub is the user’s uid. Verifying it is the job of the
Firebase Admin SDK, which also owns the signing-key cache and the revocation
check.
So this integration keeps verification behind a port. The engine depends on an interface, the Admin SDK implements it at the composition root, and every test runs offline against a fake.
This recipe follows the
auth-firebase-engine
example. The generic mechanics of a bearer-token authenticate hook live in the
JWT bearer recipe; this page only covers what is
specific to Firebase Auth.
Build the integration
Section titled “Build the integration”-
Collect the project id
Terminal window FIREBASE_PROJECT_ID=your-project-idGOOGLE_APPLICATION_CREDENTIALS=/run/secrets/firebase-service-account.jsonOne value drives both checks: the project id is the expected
aud, and the expectedissis that id appended tohttps://securetoken.google.com/. The service account file is a secret; keep it in the deployment’s secret mechanism and never in the repository. -
Define the verification port
export interface FirebaseIdTokenVerifier {verifyIdToken(idToken: string,options: { readonly signal: AbortSignal },): Promise<FirebaseIdTokenClaims | null>;}The port has the same three outcomes the hook must produce: decoded claims for a valid token,
nullfor any invalid credential, and a rejection only when verification could not complete. Everything downstream — the hook, the principal mapping, the capability — depends on this interface, not onfirebase-admin. -
Adapt the Admin SDK to the port
/*** The part of `firebase-admin/auth`'s `Auth` this example uses. `getAuth()`* satisfies it structurally, so the production composition root passes the* real object and the tests pass a fake.*/export interface FirebaseAdminAuth {verifyIdToken(idToken: string,checkRevoked?: boolean,): Promise<FirebaseIdTokenClaims>;}export function createAdminIdTokenVerifier(auth: FirebaseAdminAuth,options: AdminIdTokenVerifierOptions = {},): FirebaseIdTokenVerifier {const checkRevoked = options.checkRevoked ?? true;const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {throw new TypeError("timeoutMs must be a positive integer.");}return {async verifyIdToken(idToken, { signal }) {// The provider call is bounded by both the caller's cancellation and// this verifier's own deadline, because the SDK takes neither.const bounded = AbortSignal.any([signal,AbortSignal.timeout(timeoutMs),]);try {// A cancelled request never reaches the provider.if (bounded.aborted) throw bounded.reason;return await settleWithSignal(auth.verifyIdToken(idToken, checkRevoked),bounded,);} catch (error) {if (isInvalidIdTokenError(error)) return null;throw new IdTokenVerificationUnavailableError();}},};}FirebaseAdminAuthis a structural view ofgetAuth(), so the example compiles and tests without installingfirebase-adminwhile the real object still fits.verifyIdTokentakes noAbortSignal, so the adapter supplies the bound the hook contract requires: the request’s own cancellation plus a deadline of its own. -
Separate an invalid token from an unavailable check
export const INVALID_ID_TOKEN_ERROR_CODES: ReadonlySet<string> = new Set(["auth/argument-error","auth/id-token-expired","auth/id-token-revoked","auth/invalid-argument","auth/invalid-id-token","auth/user-disabled","auth/user-not-found",]);export function isInvalidIdTokenError(error: unknown): boolean {if (typeof error !== "object" || error === null) return false;const code = (error as { readonly code?: unknown }).code;return typeof code === "string" && INVALID_ID_TOKEN_ERROR_CODES.has(code);}Only these Admin SDK codes mean “this credential is not valid” and become
null, which the adapter answers with HTTP 401. A network failure, a timeout, or an unknown code falls through to a thrownIdTokenVerificationUnavailableError, which becomes a sanitized HTTP 500. That error carries a fixed message and no cause, so no token and no provider message can reach a log through it. -
Map the claims to a principal
export function firebaseIssuer(projectId: string): string {return `https://securetoken.google.com/${projectId}`;}export function toPrincipal(claims: FirebaseIdTokenClaims,options: FirebasePrincipalOptions,): Principal | null {if (claims.iss !== firebaseIssuer(options.projectId)) return null;if (claims.aud !== options.projectId) return null;const uid =readNonEmptyString(claims.sub) ?? readNonEmptyString(claims.uid);if (uid === null) return null;const attributes: Record<string, unknown> = {};const email = readNonEmptyString(claims.email);if (email !== null) attributes.email = email;if (typeof claims.email_verified === "boolean") {attributes.emailVerified = claims.email_verified;}if (typeof claims.auth_time === "number" &&Number.isFinite(claims.auth_time)) {attributes.authTime = claims.auth_time;}const signInProvider = readNonEmptyString(readMember(claims.firebase, "sign_in_provider"),);if (signInProvider !== null) attributes.signInProvider = signInProvider;const tenantId = readNonEmptyString(readMember(claims.firebase, "tenant"));if (tenantId !== null) attributes.tenantId = tenantId;const customClaims: Record<string, unknown> = {};for (const name of options.customClaimNames ?? []) {const value = readCustomClaimValue(claims[name]);if (value !== undefined) customClaims[name] = value;}if (Object.keys(customClaims).length > 0) {attributes.customClaims = customClaims;}return { id: uid, attributes };}The
uidbecomesPrincipal.id;email,email_verified,firebase.sign_in_provider,firebase.tenant, andauth_timebecome attributes. Firebase publishes custom claims as top-level claims of the token, so they are copied by name fromcustomClaimNames— never as a blanket copy of the decoded token, which would put provider-owned and unreviewed fields into capability-visible identity.The issuer and audience are re-checked here even though the SDK already validated them. It costs two string comparisons and makes “a token minted for another Firebase project can never become a principal of this engine” a local, testable guarantee.
-
Wire the hook at the composition root
export function createFirebaseAuthenticate(options: FirebaseAuthenticationOptions,): (request: McpHttpAuthenticationRequest) => Promise<Principal | null> {const { verifier, ...principalOptions } = options;return async (request) => {const idToken = readBearerToken(request.headers);if (idToken === null ||idToken.length > MAX_ID_TOKEN_LENGTH ||!COMPACT_JWS.test(idToken)) {return null;}// A rejection here is deliberately not caught: only the verifier can// tell an invalid credential (null) from an unavailable check (throw).const claims = await verifier.verifyIdToken(idToken, {signal: request.signal,});return claims === null ? null : toPrincipal(claims, principalOptions);};}export async function startFirebaseMcpHttp(options: FirebaseMcpHttpOptions,): Promise<McpHttpServerHandle> {const { host, port, ...authenticationOptions } = options;return serveMcpHttp(engine, {...(host === undefined ? {} : { host }),...(port === undefined ? {} : { port }),auth: createFirebaseHttpAuth(authenticationOptions),});}A credential that cannot be a compact JWS is rejected before any provider call, so a malformed header costs no Firebase round trip. The verifier is a parameter: production passes the Admin SDK adapter, tests pass a fake.
-
Keep authorization in the capability
export const whoami = defineCapability({title: "Who am I",description: "Return the verified identity of the calling principal.",input,output,access: "authenticated",async run({ context }) {const { principal } = context;if (principal === null) {// Unreachable through the engine: "authenticated" is applied first.throw new EngineError({code: "UNAUTHENTICATED",message: "This capability requires an authenticated principal.",});}return {principalId: principal.id,attributes: { ...principal.attributes },};},});The capability never imports a Firebase module and never sees the token. A real capability replaces
"authenticated"with a function rule that readsattributes.customClaimsor requiresattributes.emailVerified === true. -
Use the same mapping for an embedded host
const principal = toPrincipal(verifiedClaims, {projectId,customClaimNames: ["role"],});if (principal === null) {throw new Error("The sample claims are not valid for this project.");}const result = await engine.invoke("identity.whoami",{},{ source: "direct", principal },);A Next.js route handler or a Cloud Function has already called
verifyIdToken, so it skips the HTTP hook entirely: it maps the claims it holds and invokes the engine directly. Identity still arrives through the trusted invocation boundary, never through capability input.
Run it
Section titled “Run it”The example’s runnable entrypoint is the embedded one, because it needs no credential and no Firebase project:
yarn buildyarn workspace @invokta/example-auth-firebase directIt prints the principal that the claim mapping produced:
{"principalId":"uid-demo-1","attributes":{"email":"ada@example.com","emailVerified":true,"authTime":1754400000,"signInProvider":"password","customClaims":{"role":"support-agent"}}}For the HTTP boundary, copy src/identity/ and src/mcp-http.ts into an
application that has firebase-admin installed, add the src/serve.ts entry
file from the SDK-variant aside above — src/mcp-http.ts itself exports only
functions and starts nothing — build, and start it with the project’s
environment:
FIREBASE_PROJECT_ID=your-project-id \GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/firebase-service-account.json \ node dist/serve.jsCall the capability with an ID token from a signed-in client
(user.getIdToken()):
curl -i --request POST http://127.0.0.1:3000/mcp \ --header "authorization: Bearer $FIREBASE_ID_TOKEN" \ --header 'accept: application/json, text/event-stream' \ --header 'content-type: application/json' \ --data '{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"identity_whoami","arguments":{}}}'The result echoes the uid and the enumerated attributes the token proved.
Repeat the request without the authorization header to get HTTP 401.
Verify it
Section titled “Verify it”Every test is offline: the hook runs against a fake verifier and the Admin SDK
adapter runs against a fake getAuth(), so nothing reaches Google:
yarn workspace @invokta/example-auth-firebase testThey cover a verified token, a missing header, a non-bearer scheme, a value that
is not a compact JWS, an oversized credential, a token the verifier rejects (the
expired, revoked, disabled, and malformed classes), a token minted for another
project, a wrong audience, claims without a usable subject, a cancelled request,
a verification timeout, an infrastructure failure that must reject rather than
return null, and the absence of any token material in the produced
Principal, the HTTP 401 body, and the HTTP 500 body.
Read Authorize with domain data for the policy half, and HTTP authentication for the hook contract and the secret and logging rules.