building passkeys with pwafire and firebase custom tokens

how tapped wires pwafire's webauthn helpers on the client to a simplewebauthn cloud function on the backend, with firebase auth custom tokens bridging the two.

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 sibling parseCreationOptions) take the json blob from the server and turn the base64url strings back into the ArrayBuffers webauthn wants. you do not write this yourself.
  • passkey.get returns the native PublicKeyCredential. .toJSON() flips the ArrayBuffers back to base64url so the response survives a JSON.stringify over the wire. see the pwafire passkey docs for the why.
  • User cancelled and Operation aborted aren’t errors — they’re just “user changed their mind”. return null, 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!