the gist
tapped needed passwordless sign-in. firebase auth doesn’t ship a passkey provider out of the box, so we glued one together: pwafire on the client wraps the webauthn ceremony, @simplewebauthn/server on a cloud function verifies the response, and firebase auth still owns the session via a custom token.
three moving pieces. let me walk through each.
the client hook
the whole user-facing surface is one react hook — usePasskey — exposing register, authenticate, plus loading and error state.
import { passkey } from "pwafire";
const requestAndVerify = async (email?: string): Promise<string | null> => {
const { challengeId, ...challenge } = await fetchLoginPasskeyChallenge(email);
const parsed = passkey.parseRequestOptions(challenge);
if (!parsed.ok || !parsed.options) {
throw new Error(parsed.message || "passkeys are not supported on this device");
}
const result = await passkey.get(parsed.options);
if (!result.ok || !result.credential) {
if (result.message === "User cancelled" || result.message === "Operation aborted") return null;
throw new Error(result.message || "failed to authenticate");
}
const { token } = await verifyLoginPasskey(result.credential.toJSON(), challengeId);
return token;
};
a few things to notice:
parseRequestOptions(and its siblingparseCreationOptions) take the json blob from the server and turn the base64url strings back into theArrayBuffers webauthn wants. you do not write this yourself.passkey.getreturns the nativePublicKeyCredential..toJSON()flips theArrayBuffers back to base64url so the response survives aJSON.stringifyover the wire. see the pwafire passkey docs for the why.User cancelledandOperation abortedaren’t errors — they’re just “user changed their mind”. returnnull, don’t throw.- the function returns a firebase custom token, not a session. the session belongs to firebase auth.
register follows the same shape with passkey.create and posts result.credential.toJSON() to /auth/passkey/register/verify.
the firebase function
four endpoints, all in one cloud function file:
POST /auth/passkey/register/challenge (authed)
POST /auth/passkey/register/verify (authed)
POST /auth/passkey/login/challenge (public)
POST /auth/passkey/login/verify (public)
registration is gated behind an existing firebase auth session — you sign up with email or oauth first, then add a passkey. login is public because the whole point is the passkey is the credential.
challenges live in firestore
await db.collection(CHALLENGES_COLLECTION).doc(uid).set({
challenge: options.challenge,
createdAt: Timestamp.now(),
expireAt: Timestamp.fromMillis(Date.now() + CHALLENGE_TTL_MS),
});
a ttl policy on expireAt lets firestore garbage-collect stale challenges automatically. for registration the doc id is the user’s uid. for login the user is unknown, so we generate a random challengeId and ship it back to the client alongside the options — the client echoes it on verify so we can find the right challenge.
simplewebauthn does the crypto
const verification = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge: challenge,
expectedOrigin,
expectedRPID: rpId,
});
do not write this yourself. webauthn verification is a list of checks long enough to get wrong in production — challenge, origin, rpid, attestation, signature, counter. @simplewebauthn/server runs all of them.
on a verified registration we store the credential in a per-user firestore subcollection:
await saveUserPasskey(uid, {
credentialId: credentialID,
publicKey: Buffer.from(credentialPublicKey).toString("base64url"),
counter,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
transports: registrationResponse.response.transports as AuthenticatorTransport[],
createdAt: Timestamp.now(),
});
transports matters more than it looks — passing it back in excludeCredentials on the next registration is what stops the same authenticator being enrolled twice.
the firebase auth bridge
login verify is where it gets interesting. once verifyAuthenticationResponse returns verified: true, we bump the counter and mint a custom token:
const token = await auth.createCustomToken(uid);
res.status(200).json({ token });
the client then does:
import { signInWithCustomToken } from "firebase/auth";
const token = await usePasskey().authenticate();
if (token) await signInWithCustomToken(auth, token);
and now the user is signed into firebase auth like any other user — id tokens, security rules, the lot. passkeys live next to firebase, not inside it.
one gotcha: createCustomToken requires the runtime service account to have iam.serviceAccounts.signBlob (the service account token creator role on itself). without it you get a cryptic iam error at runtime, not at deploy. we catch and remap to a sanitised 503 so the client gets a useful message instead of leaking the iam detail.
the data model
users/{uid}/passkeys/{credentialId}
credentialId: string // base64url, matches authenticator's credential id
publicKey: string // base64url
counter: number // bumped on every login
deviceType: "singleDevice" | "multiDevice"
backedUp: boolean // is this passkey synced (e.g. icloud keychain)?
transports: AuthenticatorTransport[]
createdAt: Timestamp
lastUsedAt?: Timestamp
we also keep a top-level lookup so login (which doesn’t know the uid yet) can find a user by credentialId — without that, every login attempt would be a full scan.
relying party — the one config that bites
const { rpId, expectedOrigin } = resolvePasskeyRelyingParty(req.get("origin"));
rpId is the bare domain (tapped.co.ke). expectedOrigin is the full origin including scheme (https://tapped.co.ke). they must match what the browser sent at creation time — change the domain after a passkey was created and that passkey is dead.
for local dev we resolve localhost and the firebase hosting preview channel to their own rp configs. one helper, called from every endpoint.
what the user gets
- click “sign in with passkey” → biometric prompt → signed into firebase auth
- no password to forget, no email verification round-trip, no oauth redirect
- works on the same firebase id token the rest of the app already understands
the whole thing is ~250 lines of cloud function code and one ~90-line hook. the heavy lifting is pwafire (client) and simplewebauthn (server) — we just wire them to firebase auth’s custom token endpoint and let firestore hold the state.
happy shipping folks!