sdxc

[ Identity & security ]

@sdxc/passkey

Passkeys end to end: a WebAuthn browser API and a verifying relying party

npm add @sdxc/passkey remix
pnpm add @sdxc/passkey remix
yarn add @sdxc/passkey remix
bun add @sdxc/passkey remix
Used by
auth-saas

Passkeys end to end: a WebAuthn browser API and a verifying relying party.

@sdxc/passkey/client runs a ceremony in the browser with one call and hands back JSON to post. @sdxc/passkey/server issues the options and challenge for each ceremony and verifies what comes back using only WebCrypto, so it runs on Workers, Node and Deno. RegistrationResponseJSON, AuthenticationResponseJSON and the options types are the DOM WebAuthn types.

Installation

npm add @sdxc/passkey

Every call answers a Result; install @sdxc/result to import isFailure and isSuccess.

Usage

Create a relying party

The relying party is the site passkeys are bound to. It is stateless, so one instance at module scope serves every request.

import { RelyingParty } from "@sdxc/passkey/server";

export const RELYING_PARTY = new RelyingParty({ id: "example.com", name: "Example" });

Register a passkey

Registration enrolls a passkey for an account that is already signed in. The server issues a challenge and options: keep the challenge until the browser answers, and send the options.

let { challenge, options } = RELYING_PARTY.register({
	user: { id: account.id, name: account.email },
	exclude: account.passkeys.map((passkey) => passkey.id),
});

session.set("passkeyChallenge", challenge);
return Response.json(options);

user.id is stored on the authenticator and comes back on every sign-in, so use an opaque account id; name is what the browser lists the credential under. exclude makes the browser refuse to enroll a device the account already has.

The browser runs the ceremony and posts the response back as JSON:

import { Passkey } from "@sdxc/passkey/client";
import { isFailure } from "@sdxc/result";

let result = await Passkey.register(options);
if (isFailure(result)) return showMessage(result.error.message);

await fetch("/passkeys", {
	method: "POST",
	headers: { "content-type": "application/json" },
	body: JSON.stringify(result.data),
});

The server verifies it against the challenge and stores the credential it returns:

import { isFailure } from "@sdxc/result";

let challenge = session.get("passkeyChallenge");
session.unset("passkeyChallenge");

let result = await RELYING_PARTY.verifyRegistration(request, { challenge });
if (isFailure(result)) return new Response(null, { status: 400 });

await db.passkeys.insert({ accountId: account.id, ...result.data });

Sign in with a passkey

Leave allow out and the browser offers every passkey it holds for the site, so nobody types an identifier first.

let { challenge, options } = RELYING_PARTY.authenticate();
let result = await Passkey.authenticate(options);

Find the stored credential by the id the response carries, verify against it, and record the new signature counter:

let response = (await request.json()) as AuthenticationResponseJSON;
let passkey = await db.passkeys.find(response.id);

let result = await RELYING_PARTY.verifyAuthentication(response, { challenge, passkey });
if (isFailure(result)) return new Response(null, { status: 401 });

await db.passkeys.update(passkey.id, { counter: result.data.counter });
let accountId = passkey.accountId;

Offer passkeys in the sign-in field

autofill places the passkeys in an input's autocomplete menu instead of opening a sheet. Start it as the page loads; it resolves once somebody picks a credential.

<input type="text" name="email" autocomplete="username webauthn" />
import { Passkey } from "@sdxc/passkey/client";
import { isSuccess } from "@sdxc/result";

if (await Passkey.isAutofillSupported()) {
	let result = await Passkey.autofill(options);
	if (isSuccess(result)) await submitAssertion(result.data);
}

API

@sdxc/passkey/client

Passkey.register(options, ceremony?): Promise<Result<RegistrationResponseJSON, PasskeyError>>

Runs navigator.credentials.create() with the options rp.register() issued and answers the response as JSON to post.

Passkey.authenticate(options, ceremony?): Promise<Result<AuthenticationResponseJSON, PasskeyError>>

Runs navigator.credentials.get() and prompts immediately.

Passkey.autofill(options, ceremony?): Promise<Result<AuthenticationResponseJSON, PasskeyError>>

The same ceremony under conditional mediation: it stays pending until somebody picks a credential from an input marked autocomplete="username webauthn".

A browser keeps one ceremony open at a time, so each of the three calls cancels the ceremony already open. ceremony.signal takes an AbortSignal that cancels this one as well.

Passkey.cancel(): void

Cancels the open ceremony, which releases a pending autofill when the person leaves the sign-in step.

Passkey.isSupported(): boolean

Whether this page can run a ceremony: false on an insecure origin and on a browser without WebAuthn.

Passkey.isAutofillSupported(): Promise<boolean>

Whether the browser can offer passkeys in an autofill menu.

Passkey.isPlatformSupported(): Promise<boolean>

Whether the device holds passkeys itself, behind a biometric or PIN.

@sdxc/passkey/server

new RelyingParty(options: RelyingParty.Options)

  • id: the registrable domain credentials are bound to, such as "example.com"; a parent domain covers its subdomains.

  • name: shown in the passkey prompt.

  • origin: origin or origins allowed to run ceremonies. Defaults to https://<id>.

  • userVerification: "required" makes a missing biometric or PIN a failure. Defaults to "preferred".

  • algorithms: COSE algorithms offered, in preference order. Defaults to [ES256, RS256, EDDSA].

  • timeout: milliseconds the browser keeps the prompt open. Defaults to 300000.

  • allowFramed: whether a ceremony may run inside a cross-origin frame. Defaults to false.

rp.register(options: RelyingParty.RegisterOptions): RelyingParty.Ceremony<PublicKeyCredentialCreationOptionsJSON>

Issues a fresh challenge and the options for Passkey.register. Takes user ({ id, name, displayName? }) and optionally exclude, residentKey (default "preferred"), attachment and userVerification. Throws RangeError when user.id encodes to more than 64 bytes.

rp.verifyRegistration(response, { challenge, userVerification? }): Promise<Result<RegisteredPasskey, PasskeyError>>

Verifies a registration response, or a Request whose application/json body carries one, and answers the credential to store. A none or self-signed packed attestation is verified; a statement carrying a certificate chain is refused with AttestationError.

rp.authenticate(options?: RelyingParty.AuthenticateOptions): RelyingParty.Ceremony<PublicKeyCredentialRequestOptionsJSON>

Issues a fresh challenge and the options for Passkey.authenticate or Passkey.autofill. Takes allow and userVerification; without allow the ceremony is usernameless.

rp.verifyAuthentication(response, { challenge, passkey, userVerification? }): Promise<Result<AuthenticatedPasskey, PasskeyError>>

Verifies an assertion, or a Request carrying one, against the stored passkey. Store the counter it answers: a non-zero counter that fails to advance past the stored one fails with CounterError.

ES256, RS256, EDDSA

COSE algorithm identifiers (-7, -257, -8) for the algorithms option.

Errors

Every failure is a PasskeyError, exported from both entry points; a subclass names the step that refused.

  • Client: UnsupportedError, CancelledError (the person dismissed the prompt), AlreadyRegisteredError (the device is in exclude), InvalidOptionsError, CeremonyError.

  • Server: MalformedResponseError, ChallengeMismatchError, OriginMismatchError, CrossOriginError, RelyingPartyMismatchError, AttestationError, UserPresenceError, UserVerificationError, CredentialMismatchError, CounterError, UnsupportedAlgorithmError.

Types

RelyingParty.Descriptor

A known credential, as its id or as { id, transports }. Passing stored transports lets the browser go straight to the right authenticator.

RelyingParty.Ceremony<T>

{ challenge, options }: the challenge to keep until verification and the options to send.

RegisteredPasskey

The row to store: id, publicKey, algorithm, counter, transports, aaguid (the authenticator model), attestation, userVerified, syncable and backedUp.

StoredPasskey

What verifyAuthentication reads from that row: { id, publicKey, counter }.

AuthenticatedPasskey

id, the counter to store, userHandle (the user.id given at registration, for a discoverable credential), userVerified and backedUp.

Passkey.CeremonyOptions

{ signal?: AbortSignal }.

Pattern: Two JSON Endpoints Per Ceremony

A ceremony spans two requests, so the challenge has to outlive the first one. Spend it on read, so one challenge buys exactly one verification.

import { RelyingParty } from "@sdxc/passkey/server";
import { isFailure } from "@sdxc/result";

const RELYING_PARTY = new RelyingParty({
	id: "example.com",
	name: "Example",
	origin: ["https://example.com", "https://www.example.com"],
});

export async function issueChallenge(session: Session): Promise<Response> {
	let { challenge, options } = RELYING_PARTY.authenticate();
	session.set("passkeyChallenge", challenge);
	return Response.json(options);
}

export async function verifyAssertion(request: Request, session: Session): Promise<Response> {
	let challenge = session.get("passkeyChallenge");
	session.unset("passkeyChallenge");
	if (typeof challenge !== "string") return Response.json({ error: "rejected" }, { status: 400 });

	let response = (await request.json()) as AuthenticationResponseJSON;
	let passkey = await db.passkeys.find(response.id);
	if (!passkey) return Response.json({ error: "rejected" }, { status: 400 });

	let result = await RELYING_PARTY.verifyAuthentication(response, { challenge, passkey });
	if (isFailure(result)) {
		logger.warn("passkey rejected", { reason: result.error.name });
		return Response.json({ error: "rejected" }, { status: 400 });
	}

	await db.passkeys.update(passkey.id, { counter: result.data.counter });
	await signIn(session, passkey.accountId);
	return Response.json({ redirect: "/dashboard" });
}

Every refusal answers the same response and logs the error's name, so a caller learns nothing about which check failed.

Pattern: Autofill With A Button Fallback

The page starts an autofill ceremony on load and keeps a button for browsers that skip the menu. Clicking the button cancels the pending autofill, and leaving the page releases it.

import { CancelledError, Passkey } from "@sdxc/passkey/client";
import { isFailure } from "@sdxc/result";

async function signIn(autofill: boolean): Promise<void> {
	let options = await fetch("/passkey/challenge", { method: "POST" }).then((r) => r.json());

	let result = autofill ? await Passkey.autofill(options) : await Passkey.authenticate(options);
	if (isFailure(result)) {
		if (result.error instanceof CancelledError) return;
		return showMessage(result.error.message);
	}

	let verified = await fetch("/passkey/verify", {
		method: "POST",
		headers: { "content-type": "application/json" },
		body: JSON.stringify(result.data),
	});
	if (!verified.ok) return showMessage("That passkey was not accepted.");

	let { redirect } = (await verified.json()) as { redirect: string };
	location.assign(redirect);
}

if (Passkey.isSupported()) {
	button.hidden = false;
	button.addEventListener("click", () => void signIn(false));
	if (await Passkey.isAutofillSupported()) void signIn(true);
}

window.addEventListener("pagehide", () => Passkey.cancel());

Pattern: Passkeys As A Second Factor

After a password, ask for one of the account's own passkeys and require the biometric or PIN on both sides of the ceremony.

import { RelyingParty } from "@sdxc/passkey/server";
import { isFailure } from "@sdxc/result";

let rp = new RelyingParty({ id: "example.com", name: "Example" });

let { challenge, options } = rp.authenticate({
	allow: account.passkeys.map((passkey) => ({ id: passkey.id, transports: passkey.transports })),
	userVerification: "required",
});

let result = await rp.verifyAuthentication(request, {
	challenge,
	passkey,
	userVerification: "required",
});
if (isFailure(result)) return new Response(null, { status: 401 });

Pattern: Enrolling This Device

Restrict registration to a discoverable credential on the device itself, and offer it only where the device can hold one.

import { Passkey } from "@sdxc/passkey/client";

let offerEnrollment = await Passkey.isPlatformSupported();
import { EDDSA, ES256, RelyingParty } from "@sdxc/passkey/server";

let rp = new RelyingParty({
	id: "example.com",
	name: "Example",
	algorithms: [ES256, EDDSA],
	userVerification: "required",
});

let { challenge, options } = rp.register({
	user: { id: account.id, name: account.email, displayName: account.fullName },
	exclude: account.passkeys.map((passkey) => ({ id: passkey.id, transports: passkey.transports })),
	residentKey: "required",
	attachment: "platform",
});

syncable and backedUp on the registered passkey tell whether it survives losing the device, which is worth showing beside it in the account's passkey list.

Pattern: Telling The Person What Happened

The client errors map to the answers a form actually gives.

import {
	AlreadyRegisteredError,
	CancelledError,
	Passkey,
	UnsupportedError,
} from "@sdxc/passkey/client";
import { isFailure } from "@sdxc/result";

let result = await Passkey.register(options);

if (isFailure(result)) {
	if (result.error instanceof CancelledError) return;
	if (result.error instanceof AlreadyRegisteredError) return goToSignIn();
	if (result.error instanceof UnsupportedError) return offerAnotherMethod();
	return showMessage(result.error.message);
}

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.