Auth guides
10 · Passkeys

Passkeys.

Face ID, Touch ID, Windows Hello and hardware security keys — as a second factor next to a password, or on their own with no password at all. Auth verifies the WebAuthn ceremony; your frontend calls navigator.credentials and forwards the result. With @productcraft/auth each ceremony is one call.


1

Turn passkeys on for the app

Passkeys are off by default on every app. Enabling them means writing one thing you should choose carefully: the relying-party id.

Open the app in the console, go to Settings → Passkeys, and fill in three fields:

  • Passkeys enabled — the master switch. Turning it off later leaves registered passkeys in place; they simply cannot be used until it is turned back on.
  • Relying-party id — a bare domain, no scheme and no port. example.com, not https://app.example.com. It must be the host of, or a parent domain of, one of the app's allowed redirect origins; example.com covers app.example.com and checkout.example.com, but not notexample.com.
  • Display name — what the operating system's passkey prompt calls you (“Save a passkey for Acme”). Cosmetic, changeable at any time, not part of the credential.

Choose the relying-party id once

The relying-party id is not a setting, it is a binding. It is hashed into every credential at registration time. Changing it later does not turn those passkeys off — it makes every one of them fail verification with an rp-id-hash mismatch, which to the user looks like their passkey stopped being trusted and to you looks like an attack. Auth refuses the change outright with a 409 while any passkey exists, and the console warns you before you try.

The practical consequence: if you might ever move sign-in from app.example.com to accounts.example.com, set the rp id to the parent example.com now. A parent domain covers both. Going the other way — narrowing from a parent to a subdomain, or widening from a subdomain to a parent — is not possible once a single user has enrolled.

Ceremony origins

The Passkeys page also lists the app's ceremony origins: the entries of allowed_redirect_origins the rp id actually covers. That list is read-only — you edit it under Auth config → Federated sign-in policy — but it is worth reading, because an empty list while passkeys are enabled is the one misconfiguration that is otherwise invisible: every ceremony is refused with 412 and nothing else on the app says why. The usual cause is an edit to the origin list that dropped the site users sign in on.

The same config is available over HTTP for IaC:

PUT /v1/apps/:app_id/webauthn-config
curl -X PUT https://api.auth.productcraft.co/v1/apps/$APP_ID/webauthn-config \
  -H "authorization: Bearer $PCFT_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "enabled": true,
    "rp_id": "example.com",
    "rp_name": "Acme",
    "require_user_verification": true
  }'

# => {
#      "enabled": true,
#      "rp_id": "example.com",
#      "rp_name": "Acme",
#      "require_user_verification": true,
#      "ceremony_origins": ["https://app.example.com"],
#      "credential_count": 0
#    }

credential_count is how many passkeys exist on the app. Non-zero means rp_id is frozen — read it before you plan a change rather than discovering it from the 409.

require_user_verification governs second-factor ceremonies only. Passwordless sign-in always requires user verification whatever this says: when the passkey is the only credential, a presence-only tap means “someone is holding this key”, which is not an authentication.


2

Two ways to run a ceremony

Every passkey flow is the same three steps: ask Auth for options, hand them to navigator.credentials, post the result back. The SDK does all three in one call; the raw HTTP is shown next to it for everyone else.

@productcraft/auth 0.7.0 or later adds a passkeys group to the consumer scope — auth.consumer("acme").passkeys — with one method per ceremony: enroll(), signIn(), completeMfaChallenge() and stepUp(). Each wraps the options call, the navigator.credentials prompt and the verify call, with the base64url encoding handled in both directions.

These four run in the browser. The rest of @productcraft/auth is typically used server-side, but a passkey ceremony is a call to navigator.credentials, so the passkey helpers belong in your frontend bundle, on a page served from one of the app's ceremony origins. Importing the package in Node is still fine — nothing touches a browser global until you call a ceremony — and calling one there rejects with a PasskeyError of code unsupported rather than throwing on a missing window.

Gate the button with isPasskeySupported(). It answers true only in a secure context with a working PublicKeyCredential and navigator.credentials, never throws, and is false in Node:

Show the passkey button only where it can work
import { isPasskeySupported } from "@productcraft/auth";

if (!isPasskeySupported()) {
  // Old browser, http:// origin, or not a browser at all.
  hidePasskeyButton();
}

Two error types come out of the helpers, and they are worth telling apart. Anything the browser refuses — the user dismissed the prompt, the page's origin is not covered by the rp id, the authenticator already holds this credential — surfaces as a PasskeyError with a code of cancelled, timeout, invalid_state, security, no_credential, unsupported or unknown, and the original DOMException on cause. Anything Auth refuses — a 401 on verify, a 412 because passkeys are off — is the same AuthHttpError (status, data) every other SDK call throws.

In the HTTP tabs below, api(path, body) is a thin fetch wrapper that POSTs JSON to https://api.auth.productcraft.co/<app_slug>/v1 and sends the end-user's bearer token on the /me/… routes. decodeOptions and encodeCredential are the two encoding helpers from section 5 — the SDK exports them too, so you can mix and match.


3

Flow A — a passkey as a second factor

The user already has a password. They add a passkey the same way they would add an authenticator app, and it then satisfies the MFA step at sign-in.

3a · Enrol

POST /:app_slug/v1/me/mfa/factors with { "type": "webauthn" }, authenticated as the end-user. The response carries a pending factor plus enrollment.public_key to hand to navigator.credentials.create(). Confirm with POST /:app_slug/v1/me/mfa/factors/:id/verify, which returns the user's recovery codes once.

@productcraft/auth — enrol a passkey
import { Auth } from "@productcraft/auth";

// Authenticated as the end-user: pass their access token.
const me = new Auth({
  auth: { type: "bearer", token: accessToken },
}).consumer("acme");

const { factor, recovery_codes } = await me.passkeys.enroll({
  label: "MacBook", // optional; shown in the user's factor list
});
// factor.enabled === true
// recovery_codes — show these once, they are not retrievable later.

The factor stays disabled until the verify call succeeds, so an abandoned enrolment leaves nothing usable behind.

3b · Sign in

Sign-in is unchanged up to the point where MFA is required. POST /:app_slug/v1/auth/signin answers { "mfa_required": true, "mfa_token": "…", "factors": [...] }. When that list contains a webauthn factor, ask for assertion options and submit the result as credential instead of code:

@productcraft/auth — complete the MFA challenge
import { Auth } from "@productcraft/auth";

const anon = new Auth().consumer("acme");
const r = await anon.auth.signin({ identifier, password });

let session = r;
if ("mfa_required" in r && r.mfa_required) {
  session = await anon.passkeys.completeMfaChallenge({
    mfa_token: r.mfa_token,
  });
}
// session.access_token — amr: ["pwd", "webauthn"], mfa_at set to now.

Possession of the mfa_token — i.e. a correct password — is the authority for the options call. Nothing before that point reveals whether an account exists or holds passkeys.

3c · Step-up

The same pair exists for re-confirming identity inside a live session: POST /:app_slug/v1/me/mfa/step-up/webauthn/options then POST /:app_slug/v1/me/mfa/step-up with credential. A passkey assertion is never tried as a recovery code, so a failed step-up never burns one.

@productcraft/auth — step-up
import { Auth } from "@productcraft/auth";

const me = new Auth({
  auth: { type: "bearer", token: session.access_token },
}).consumer("acme");

const { amr, mfa_at } = await me.passkeys.stepUp({
  signal: AbortSignal.timeout(60_000), // give up if the prompt sits unanswered
});
// amr now includes "webauthn", mfa_at is fresh — call your sensitive endpoint.

4

Flow B — passwordless sign-in

Two calls, no password, no email address. The authenticator finds its own passkey for your relying-party id.

@productcraft/auth — passwordless sign-in
import { Auth, isPasskeySupported, PasskeyError } from "@productcraft/auth";

if (!isPasskeySupported()) hidePasskeyButton();

const anon = new Auth().consumer("acme");

try {
  const session = await anon.passkeys.signIn({
    mediation: "conditional", // autofill-style UI; omit for a plain prompt
  });
  // session.access_token — amr: ["webauthn"], fresh mfa_at.
} catch (e) {
  if (e instanceof PasskeyError && e.code === "cancelled") {
    // The user dismissed the prompt, or no passkey matched. Not an error to show.
  } else {
    throw e; // AuthHttpError 401 for a forged / replayed / expired assertion
  }
}

Why the options call takes no email address

/auth/passkey/options accepts no identifier, looks nothing up, and returns an always-empty allowCredentials. That is deliberate. An endpoint that returned a credential list for a registered address and an empty list for an unregistered one would answer “does this account exist here?” in a single unauthenticated request — exactly the oracle the sign-in endpoint was hardened against. Discoverable (resident) credentials make the allow-list unnecessary: the authenticator already knows which passkey belongs to your rp id.

The SDK holds the same line: signIn() has no identifier parameter to pass. The only body member it will send is tenant_id, and only when you give one on a multi-tenant app.

/auth/passkey/verify answers a uniform 401 for a forged assertion, an unknown credential, a challenge that was never issued, one already used, and one that expired. You cannot tell those apart from outside, and neither can anyone else.

Because passwordless sign-in mandates user verification, the resulting tokens carry amr: ["webauthn"] and a fresh mfa_at — a passkey sign-in satisfies an MFA-recency policy on its own, with no second step.


5

What the SDK does under the hood — the encoding layer

WebAuthn speaks ArrayBuffer; JSON does not. Every buffer crossing the wire is base64url, and getting the alphabet or the padding wrong produces a signature failure rather than a parse error. If you are not on Node or TypeScript, this is the part you write yourself.

base64url, not base64: - and _ instead of + and /, and no = padding. Two helpers cover every call above; paste them once. This is, character for character, what the SDK ships — and it exports each piece by name if you want them without the ceremony wrappers: toB64u, fromB64u, decodeOptions, encodeCredential, plus createPasskeyCredential(publicKey) and getPasskeyCredential(publicKey) for the navigator.credentials step on its own.

passkeys.ts — the whole encoding layer
const toB64u = (buf: ArrayBuffer): string =>
  btoa(String.fromCharCode(...new Uint8Array(buf)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');

const fromB64u = (s: string): Uint8Array => {
  const b64 = s.replace(/-/g, '+').replace(/_/g, '/');
  const bin = atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, '='));
  return Uint8Array.from(bin, (c) => c.charCodeAt(0));
};

/** Auth returns public_key with every buffer base64url-encoded;
 *  navigator.credentials wants real ArrayBuffers. */
function decodeOptions(publicKey: any) {
  const out = { ...publicKey, challenge: fromB64u(publicKey.challenge) };
  if (publicKey.user) {
    out.user = { ...publicKey.user, id: fromB64u(publicKey.user.id) };
  }
  for (const list of ['excludeCredentials', 'allowCredentials'] as const) {
    if (publicKey[list]) {
      out[list] = publicKey[list].map((c: any) => ({
        ...c,
        id: fromB64u(c.id),
      }));
    }
  }
  return out;
}

/** …and the reverse, for the credential you send back. */
function encodeCredential(cred: PublicKeyCredential) {
  const r = cred.response as any;
  const response: Record<string, string | null> = {
    clientDataJSON: toB64u(r.clientDataJSON),
  };
  if (r.attestationObject) response.attestationObject = toB64u(r.attestationObject);
  if (r.authenticatorData) response.authenticatorData = toB64u(r.authenticatorData);
  if (r.signature) response.signature = toB64u(r.signature);
  if (r.userHandle) response.userHandle = toB64u(r.userHandle);
  return { id: cred.id, response };
}

The keys inside public_key arrive in the WebAuthn spec's camelCase — rpId, pubKeyCredParams, authenticatorSelection.residentKey — exactly as navigator.credentials wants them, so the sketch above can hand them over once the buffers are decoded. They are the browser's names, not ours to rename. The SDK's decodeOptions additionally accepts a snake_case public_key and renames it on the way in (values untouched), so it keeps working against an Auth build from before 2026-09-20 that snake_cased those keys.

The member names inside response clientDataJSON, attestationObject, authenticatorData — are fixed by the WebAuthn spec and are sent verbatim. Together with the keys inside public_key, they are the only places in the Auth API that are not snake_case on the wire, because they are not ours to rename. Everything outside the credential and public_key objects follows the usual snake_case convention.


6

What to expect when it goes wrong

The three failures worth recognising on sight.

  • The button does nothing and no request is sent. The browser refused the ceremony client-side with a SecurityError because the page's origin is not covered by the rp id. With the SDK this is a PasskeyError of code security. Check the ceremony-origin list on the Passkeys page against the host you are actually serving from.
  • 412 on every ceremony. Either passkeys are not enabled for the app, or they are enabled and no allowed redirect origin matches the rp id. The error body names which; the SDK throws it as an AuthHttpError with status === 412.
  • 409 on the config write. You are trying to move rp_id while passkeys exist. Every one of them is bound to the old value; they have to be removed first. Read credential_count on the GET to see how many.

Challenges expire five minutes after they are minted and are single-use, so a stale tab retrying an old ceremony gets a 401 rather than a session. Pass an AbortSignal to any SDK ceremony to bound how long the prompt may sit open; an aborted prompt surfaces as PasskeyError cancelled, a browser-side timeout as timeout.


Next

Where to go from here

  • 02 · Quickstart — if signup + signin are not wired up yet, start there.
  • 06 · Verify tokens — what to do with the amr and mfa_at claims a passkey sign-in produces.
  • API reference — the full request and response schemas for every endpoint above.