A passkey is a WebAuthn credential that the user’s device or password manager keeps. Signing in is a fingerprint, face or PIN check, with no password to phish, reuse or leak. The browser creates and uses the passkey; your methods issue challenges, verify responses natively, and keep the records.
How it works
Registration and sign-in are each a pair of mutations around one browser call:
- Begin. Build options with a fresh challenge (
webauthn.registrationOptionsorwebauthn.authenticationOptions) and store the challenge. - The browser. Pass the options to
navigator.credentials.create()orget(). The user confirms with their device. - Finish. Verify
credential.toJSON()against the stored challenge and delete it. Registration stores the new passkey; sign-in opens a session.
Each challenge is accepted once. A failed verification rolls back the whole mutation, the deletion included, so the user can retry until the challenge expires.
The server
// Passwordless accounts with passkeys. Each ceremony is a begin/finish pair of
// mutations: begin returns options for navigator.credentials.create() or get()
// and stores the challenge; finish verifies the browser's credential.toJSON()
// and consumes it, so every challenge is accepted at most once.
import { collection, define, fail, mutation, query, v } from "@flower-js/sdk";
import type { MutationContext } from "@flower-js/sdk";
import { base64url, nacl, sha256, webauthn } from "@flower-js/sdk/crypto";
import type { WebAuthnCredential } from "@flower-js/sdk/crypto";
import { scheduler } from "@flower-js/sdk/scheduler";
// Your site: the RP ID is its domain, and origins are exactly what browsers report.
const RP = { id: "garden.example", name: "Flower garden" };
const ORIGINS = ["https://garden.example"];
const CEREMONY_MS = 5 * 60_000;
const SESSION_MS = 7 * 24 * 60 * 60_000;
interface Account { handle: string; name: string; createdAt: number }
interface Passkey { account: string; credential: WebAuthnCredential; createdAt: number; lastUsedAt: number | null }
interface Ceremony { kind: "register" | "signIn"; challenge: string; account: { handle: string; name: string } | null; expiresAt: number }
const accounts = collection<Account>("accounts");
const passkeys = collection<Passkey>("passkeys").index("byAccount", ["account"]);
const ceremonies = collection<Ceremony>("passkeyCeremonies");
// Keyed by the token's SHA-256, so a leaked record cannot sign anyone in.
const sessions = collection<{ account: string; expiresAt: number }>("sessions");
const expire = mutation("internal.passkey.expire", { args: v.string() }, (ctx, id) => {
ctx.delete(ceremonies, id);
return null;
});
const cleanup = scheduler("passkeyCeremonyTimers", { expire });
function begin(ctx: MutationContext, kind: Ceremony["kind"], challenge: string, account: Ceremony["account"]): string {
const id = base64url.encode(nacl.randomBytes(16));
ctx.set(ceremonies, id, { kind, challenge, account, expiresAt: ctx.now() + CEREMONY_MS });
cleanup.after(ctx, id, CEREMONY_MS, "expire", id);
return id;
}
// A failed verification rolls this back too, so the user may retry until it expires.
function finish(ctx: MutationContext, id: string, kind: Ceremony["kind"]): Ceremony {
const ceremony = ctx.get(ceremonies, id);
if (!ceremony || ceremony.kind !== kind || ceremony.expiresAt <= ctx.now()) fail("CEREMONY_EXPIRED", "Start again");
ctx.delete(ceremonies, id);
cleanup.cancel(ctx, id);
return ceremony;
}
const sessionKey = (token: string) => base64url.encode(sha256(new Uint8Array(Array.from(token, (character) => character.charCodeAt(0)))));
const ceremonyArgs = v.object({ ceremony: v.string({ min: 1, max: 64 }), response: v.json() });
// Signed in, this adds a passkey to your account; otherwise it opens a new one.
const registerBegin = mutation("passkey.register.begin", {
access: "public", args: v.object({ name: v.optional(v.string({ min: 1, max: 64 })) }),
}, (ctx, args) => {
const signedIn = ctx.principal();
let account: { handle: string; name: string };
if (signedIn) {
account = ctx.get(accounts, signedIn.subject) ?? fail("ACCOUNT_NOT_FOUND", "Your account no longer exists");
} else {
if (!args.name) fail("NAME_REQUIRED", "Choose a name for the new account");
if (ctx.get(accounts, args.name)) fail("NAME_TAKEN", "That name is taken; sign in to add a passkey to it");
account = { handle: base64url.encode(nacl.randomBytes(16)), name: args.name };
}
const options = webauthn.registrationOptions({
rp: RP,
user: { id: account.handle, name: account.name },
exclude: ctx.query(passkeys.by("byAccount").eq(account.name)).map(({ credential }) => ({ id: credential.id, transports: credential.transports })),
});
return { ceremony: begin(ctx, "register", options.challenge, { handle: account.handle, name: account.name }), options };
});
const registerFinish = mutation("passkey.register.finish", { access: "public", args: ceremonyArgs }, (ctx, args) => {
const { challenge, account } = finish(ctx, args.ceremony, "register");
const { credential } = webauthn.verifyRegistration(args.response, { challenge, origin: ORIGINS, rpId: RP.id });
if (ctx.get(passkeys, credential.id)) fail("PASSKEY_EXISTS", "This passkey is already registered");
const existing = ctx.get(accounts, account!.name);
if (existing && existing.handle !== account!.handle) fail("NAME_TAKEN", "That name was taken meanwhile");
if (existing && ctx.principal()?.subject !== existing.name) fail("UNAUTHENTICATED", "Sign in to add a passkey to this account");
if (!existing) ctx.set(accounts, account!.name, { ...account!, createdAt: ctx.now() });
ctx.set(passkeys, credential.id, { account: account!.name, credential, createdAt: ctx.now(), lastUsedAt: null });
return { account: account!.name, passkey: credential.id, synced: credential.backupState };
});
// No allow list: the browser offers every passkey it holds for this site.
const signInBegin = mutation("passkey.signIn.begin", { access: "public" }, (ctx) => {
const options = webauthn.authenticationOptions({ rpId: RP.id });
return { ceremony: begin(ctx, "signIn", options.challenge, null), options };
});
const signInFinish = mutation("passkey.signIn.finish", { access: "public", args: ceremonyArgs }, (ctx, args) => {
const { challenge } = finish(ctx, args.ceremony, "signIn");
const id = (args.response as { id?: unknown } | null)?.id;
const passkey = (typeof id === "string" ? ctx.get(passkeys, id) : null) ?? fail("PASSKEY_UNKNOWN", "This passkey isn't registered here");
const account = ctx.get(accounts, passkey.account) ?? fail("ACCOUNT_NOT_FOUND", "The passkey's account no longer exists");
const verified = webauthn.verifyAuthentication(args.response, {
challenge, origin: ORIGINS, rpId: RP.id, credential: passkey.credential, userHandle: account.handle,
});
ctx.set(passkeys, passkey.credential.id, {
...passkey, credential: { ...passkey.credential, signCount: verified.signCount, backupState: verified.backupState }, lastUsedAt: ctx.now(),
});
const token = base64url.encode(nacl.randomBytes(32));
ctx.set(sessions, sessionKey(token), { account: account.name, expiresAt: ctx.now() + SESSION_MS });
return { account: account.name, token };
});
const me = query("account.me", { access: "authenticated" }, (ctx) => {
const name = ctx.principal()!.subject;
return {
name,
passkeys: ctx.query(passkeys.by("byAccount").eq(name)).map(({ credential, createdAt, lastUsedAt }) => ({
id: credential.id, synced: credential.backupState, createdAt, lastUsedAt,
})),
};
});
const app = define({
uses: [cleanup],
collections: [accounts, passkeys, ceremonies, sessions],
auth: {
authenticate: (ctx, credentials) => {
if (credentials === null) return null;
const valid = typeof credentials === "string" && /^[A-Za-z0-9_-]{43}$/.test(credentials);
const session = valid ? ctx.get(sessions, sessionKey(credentials)) : null;
if (!session || session.expiresAt <= ctx.now()) fail("UNAUTHENTICATED", "Sign in again");
return { subject: session.account };
},
},
http: {
"passkey.register.begin": registerBegin,
"passkey.register.finish": registerFinish,
"passkey.signIn.begin": signInBegin,
"passkey.signIn.finish": signInFinish,
"account.me": me,
},
});
export default app;- Set
RP.idto your domain andORIGINSto exactly what browsers report, scheme and port included. An RP ID ofexample.comalso covers its subdomains; list each origin you serve. - Sign-in offers every passkey the device holds for your site, so nobody types a username. The response’s
idpicks the stored passkey, and its user handle must match the account. - Signed in,
passkey.register.beginadds a passkey to your account and excludes the ones it already has. Finishing needs the same session. - Sessions are random tokens. The database keeps only their SHA-256, so its records can’t sign anyone in.
- Abandoned ceremonies expire on a timer.
The browser
import { FlowerClient } from "@flower-js/sdk";
import type app from "./passkeys.ts";
const url = "https://garden.example";
const client = new FlowerClient<typeof app>(url);
// Pass a session token to add a passkey to that account instead of opening one.
export async function register(name: string, token?: string) {
const options = token === undefined ? {} : { credentials: token };
const { value: begun } = await client.mutate("passkey.register.begin", token === undefined ? { name } : {}, options);
const credential = await navigator.credentials.create({
publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(begun.options),
}) as PublicKeyCredential;
return client.mutate("passkey.register.finish", { ceremony: begun.ceremony, response: credential.toJSON() }, options);
}
export async function signIn() {
const { value: begun } = await client.mutate("passkey.signIn.begin");
const credential = await navigator.credentials.get({
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(begun.options),
}) as PublicKeyCredential;
const { value } = await client.mutate("passkey.signIn.finish", { ceremony: begun.ceremony, response: credential.toJSON() });
return new FlowerClient<typeof app>(url, { credentials: value.token });
}parseCreationOptionsFromJSON,parseRequestOptionsFromJSONandtoJSON()are in current browsers. For older ones, a converter such as@github/webauthn-jsonproduces the same JSON.- To offer passkeys in the username field’s autofill, call
get()withmediation: "conditional"and mark the inputautocomplete="username webauthn". - If the user cancels,
create()andget()reject withNotAllowedError; nothing reaches the server.
What verification checks
- Client data: the ceremony type, your stored challenge and an allowed origin. Cross-origin ceremonies from iframes are rejected.
- Authenticator data: the SHA-256 of your RP ID, user presence, user verification when required, and consistent backup flags.
- Registration: the credential ID matches, and the key is a valid public COSE key using one of your algorithms. EdDSA, ES256 and RS256 are the default; ES384, ES512 and PS256 are available.
- Sign-in: the credential ID, the signature over the authenticator data and client data hash, the user handle, and the counter.
A rejected response fails with CRYPTO_ERROR, and its message says what failed. Attestation statements aren’t evaluated: the options request "none", which is what passkeys use, so the authenticator model (aaguid) is an unverified hint.
Counters and synced passkeys
Passkeys that sync, like those in iCloud Keychain or Google Password Manager, report a counter of zero. Hardware security keys count up. Once either side has used a counter, each sign-in must exceed the stored value; otherwise verification fails with WEBAUTHN_COUNTER, because the key may be cloned. Store the returned signCount after each sign-in.
backupEligible and backupState say whether a passkey can sync and whether it does now. A passkey that doesn’t sync is lost with its device, so suggest adding a second one.
Testing
The in-process test database has no native crypto, so run passkey flows against a local cluster. tests/e2e-passkeys.mjs does so with a software authenticator: Node’s crypto makes keys and signatures, and a few lines of CBOR build what a browser would send.