sdxc

Type to search, or start from one of these:

[ Identity & security ]

Add passkeys

Enroll a passkey for a signed-in account and sign in with it, verifying both WebAuthn ceremonies on the server.

Last updated 2026-09-29

A passkey is a key pair the person's device holds, bound to your domain, unlocked with a fingerprint, a face or a PIN. There is nothing to phish and nothing to leak from your database, because what you store is a public key. This guide adds one to a Remix v3 app: enrollment for an account that is already signed in, and a sign-in that needs no password.

@sdxc/passkey covers both halves. @sdxc/passkey/server issues ceremony options and verifies what the browser signs; @sdxc/passkey/client runs the ceremony in the browser, from a remix/ui client entry. Every failure on either side is a @sdxc/result value.

npm add @sdxc/passkey @sdxc/auth @sdxc/result @sdxc/http @sdxc/ui

Declare the endpoints

Each ceremony is two requests: the server issues a challenge, the browser signs it, the server verifies the signature. WebAuthn is a browser API, so these are JSON endpoints a client island talks to rather than form submissions.

routes/passkeys.ts
import { post, route } from "remix/routes";

export default route({
	register: {
		challenge: post("/passkeys/register/challenge"),
		verify: post("/passkeys/register/verify"),
	},
	signIn: {
		challenge: post("/passkeys/sign-in/challenge"),
		verify: post("/passkeys/sign-in/verify"),
	},
});

Nest it under passkeys in your route table in routes/web.ts, beside the dashboard a sign-in lands on, so the controllers below read routes.passkeys.signIn.verify and so on.

One relying party

The relying party holds your policy and nothing else, so one instance at module scope serves every request.

app/auth/passkeys.ts
import { RelyingParty } from "@sdxc/passkey/server";
import { env } from "cloudflare:workers";

export const RELYING_PARTY = new RelyingParty({
	id: env.PASSKEY_RP_ID,
	name: "Acme",
	origin: env.APP_ORIGIN,
	userVerification: "required",
});

id is the registrable domain credentials are bound to, such as example.com; use the parent domain when one passkey must cover several subdomains. origin lists where a ceremony may run. userVerification: "required" turns a missing biometric or PIN into a verification failure, which is what you want when the passkey is the only thing a sign-in asks for.

Keep the challenge between the two requests

The challenge is issued by one request and checked by the next, so it has to outlive the first. The session is the smallest place that holds it — the one from Sign in with OpenID Connect works as it is.

app/auth/passkey-challenge.ts
import type { RequestContext } from "remix/router";

import { sessionOf } from "@sdxc/auth/remix/context";

const CHALLENGE_KEY = "passkey:challenge";

export function keepChallenge(ctx: RequestContext, challenge: string): void {
	sessionOf(ctx).set(CHALLENGE_KEY, challenge);
}

export function spendChallenge(ctx: RequestContext): string | null {
	let session = sessionOf(ctx);
	let challenge = session.get(CHALLENGE_KEY);
	session.unset(CHALLENGE_KEY);
	return typeof challenge === "string" ? challenge : null;
}

sessionOf from @sdxc/auth reads the session Remix's session middleware put on the context, and throws when that middleware has not run. spendChallenge drops the value in the same call that reads it. A challenge that survives its ceremony is one an attacker can replay an assertion against; spent on read, it buys exactly one.

Store what the ceremony proves

Registration answers the row to store, as a RegisteredPasskey: the credential id, the publicKey, its COSE algorithm, the signature counter, the transports, and whether the credential is syncable and backedUp. Authentication later needs id, publicKey and counter back. Keep them in a table of your own, beside the account id and a suspended flag; the controllers below reach it through a small Passkeys repository of yours.

Enroll a passkey

Enrollment runs for somebody already signed in, because a passkey belongs to an account. That is also what makes it trustworthy: the account was authenticated when it enrolled, the challenge was fresh, and the origin and relying party id matched.

app/http/controllers/passkeys/register-challenge.ts
import { ok } from "@sdxc/http/response/json";
import { createAction } from "remix/router";

import { currentAccount } from "~/app/auth/current-account";
import { keepChallenge } from "~/app/auth/passkey-challenge";
import { RELYING_PARTY } from "~/app/auth/passkeys";
import { Passkeys } from "~/app/repositories/passkeys";
import routes from "~/routes/web";

export default createAction(routes.passkeys.register.challenge, async (ctx) => {
	let account = await currentAccount(ctx);
	let { challenge, options } = RELYING_PARTY.register({
		user: { id: account.id, name: account.email },
		exclude: await Passkeys.listIds(ctx.db, account.id),
	});

	keepChallenge(ctx, challenge);
	return ok(options);
});

user.id is stored on the authenticator and comes back on every assertion, and it is readable on the device, so use your opaque primary key rather than an email. exclude lists the credentials the account already has, so the browser refuses to enroll the same device twice. currentAccount is your own helper that answers the signed-in account's id and email, and Passkeys.listIds answers the credential ids the account holds.

The verify endpoint hands the request straight to the relying party, which reads and validates the JSON body itself:

app/http/controllers/passkeys/register-verify.ts
import { badRequest, ok } from "@sdxc/http/response/json";
import { isFailure } from "@sdxc/result";
import { createAction } from "remix/router";

import { currentAccount } from "~/app/auth/current-account";
import { spendChallenge } from "~/app/auth/passkey-challenge";
import { RELYING_PARTY } from "~/app/auth/passkeys";
import { Passkeys } from "~/app/repositories/passkeys";
import routes from "~/routes/web";

export default createAction(routes.passkeys.register.verify, async (ctx) => {
	let challenge = spendChallenge(ctx);
	if (!challenge) return badRequest({ error: "rejected" });

	let result = await RELYING_PARTY.verifyRegistration(ctx.request, { challenge });
	if (isFailure(result)) {
		ctx.log.warn("passkey.registration_rejected", { reason: result.error.name });
		return badRequest({ error: "rejected" });
	}

	let account = await currentAccount(ctx);
	await Passkeys.create(ctx.db, { accountId: account.id, ...result.data });
	return ok({ enrolled: true });
});

Sign in with it

Leaving allow out of authenticate() makes the ceremony usernameless: the browser offers every passkey it holds for your domain, so nobody types an identifier first.

app/http/controllers/passkeys/sign-in-challenge.ts
import { ok } from "@sdxc/http/response/json";
import { createAction } from "remix/router";

import { keepChallenge } from "~/app/auth/passkey-challenge";
import { RELYING_PARTY } from "~/app/auth/passkeys";
import routes from "~/routes/web";

export default createAction(routes.passkeys.signIn.challenge, (ctx) => {
	let { challenge, options } = RELYING_PARTY.authenticate();
	keepChallenge(ctx, challenge);
	return ok(options);
});

Verification needs the stored credential before there is anything to verify against, and the assertion names it by id, so this endpoint reads the body itself and hands the parsed response to the relying party:

app/http/controllers/passkeys/sign-in-verify.ts
import { sessionOf } from "@sdxc/auth/remix/context";
import { badRequest, ok } from "@sdxc/http/response/json";
import { CounterError } from "@sdxc/passkey/server";
import { isFailure, wrap } from "@sdxc/result";
import { createAction } from "remix/router";

import { spendChallenge } from "~/app/auth/passkey-challenge";
import { RELYING_PARTY } from "~/app/auth/passkeys";
import { Passkeys } from "~/app/repositories/passkeys";
import routes from "~/routes/web";

export default createAction(routes.passkeys.signIn.verify, async (ctx) => {
	let challenge = spendChallenge(ctx);
	let body = await wrap(
		() => ctx.request.json() as Promise<AuthenticationResponseJSON>,
	);
	if (!challenge || isFailure(body)) return badRequest({ error: "rejected" });

	let passkey = await Passkeys.find(ctx.db, body.data.id);
	if (!passkey || passkey.suspended) return badRequest({ error: "rejected" });

	let result = await RELYING_PARTY.verifyAuthentication(body.data, {
		challenge,
		passkey,
	});
	if (isFailure(result)) {
		if (result.error instanceof CounterError)
			await Passkeys.suspend(ctx.db, passkey.id);
		ctx.log.warn("passkey.rejected", { reason: result.error.name });
		return badRequest({ error: "rejected" });
	}

	await Passkeys.recordUse(ctx.db, passkey.id, result.data.counter);
	let session = sessionOf(ctx);
	session.regenerateId?.();
	session.set("userId", passkey.accountId);
	return ok({ redirect: routes.dashboard.href() });
});

AuthenticationResponseJSON is the browser's own type for what Passkey.authenticate posts. The cast only names that shape: verifyAuthentication validates the whole response and answers a MalformedResponseError for anything else, and wrap catches a body that is not JSON. Passkeys.find answers the stored id, publicKey and counter the relying party checks against, with the accountId and suspended flag beside them. Every refusal answers the same body and is logged by error name, so a caller never learns which check failed while your logs do. Record the new counter: the next assertion has to exceed it, and a CounterError means two devices are presenting the same credential — a cloned authenticator — so the credential is suspended rather than merely refused. Regenerating the session id on sign-in keeps the id held while anonymous from becoming the signed-in one, and userId stands for whichever key your app's session marks a signed-in account with.

Run the ceremony in the browser

The browser half is the one piece of the page that ships JavaScript. Keep the ceremony in a plain function: fetch the options, prompt, post the signed response, follow the redirect.

resources/components/passkey-ceremony.ts
import { CancelledError, Passkey } from "@sdxc/passkey/client";
import { isFailure } from "@sdxc/result";

export async function signInWithPasskey(challengeUrl: string, verifyUrl: string) {
	let issued = await fetch(challengeUrl, { method: "POST" });
	let options = (await issued.json()) as PublicKeyCredentialRequestOptionsJSON;

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

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

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

A CancelledError means the person dismissed the prompt, which deserves silence rather than an error message. The island wraps it in a button and shows whatever message comes back:

resources/components/passkey-sign-in.tsx
import type { Handle } from "remix/ui";

import { Button } from "@sdxc/ui";
import { clientEntry, on } from "remix/ui";

import { signInWithPasskey } from "./passkey-ceremony";

type PasskeySignInProps = { challengeUrl: string; verifyUrl: string; label: string };

export const PasskeySignIn = clientEntry(
	"/resources/components/passkey-sign-in.tsx#PasskeySignIn",
	function PasskeySignIn(handle: Handle<PasskeySignInProps>) {
		let message: string | null = null;

		async function start() {
			let { challengeUrl, verifyUrl } = handle.props;
			message = await signInWithPasskey(challengeUrl, verifyUrl);
			void handle.update();
		}

		return () => (
			<>
				<Button type="button" mix={[on("click", () => void start())]}>
					{handle.props.label}
				</Button>
				{message && <p role="alert">{message}</p>}
			</>
		);
	},
);

Render it beside your other sign-in options, passing routes.passkeys.signIn.challenge.href() and routes.passkeys.signIn.verify.href() as the two URLs; a browser without WebAuthn gets an UnsupportedError message from the same button. How client entries hydrate is covered in Build the interface with remix/ui.

Enrollment is the same shape with Passkey.register in place of Passkey.authenticate. There, an AlreadyRegisteredError means this device is already enrolled, so point the person at signing in instead.

To offer passkeys inside the browser's autocomplete menu, mark the identifier field autocomplete="username webauthn" and start Passkey.autofill(options, { signal: handle.signal }) when the island mounts. It stays pending until someone picks a credential, and handle.signal aborts it when the island disconnects, which releases the one ceremony a browser keeps open.

Where to go next