Skip to content
Ownfold
Esc
navigateopen⌘Jpreview
On this page

Passkey vault access

Recover an encrypted vault on a new browser with a dedicated WebAuthn PRF credential.

Ownfold passkey vault access is the preferred recovery and device-enrollment mechanism for a new browser when WebAuthn PRF is available. It is deliberately separate from Better Auth, Auth.js, WorkOS AuthKit, or another host login:

  1. Host authentication identifies the vault owner.
  2. A dedicated Ownfold WebAuthn credential returns a PRF secret only to the browser.
  3. That secret decrypts a portable X25519 private key locally.
  4. The portable key opens its root-key envelope locally.
  5. VaultClient.restoreWithPasskey() enrolls the browser as a normal local device and wipes the caller’s PRF buffer.

The application server receives only WebAuthn public metadata, the encrypted portable private key, and an encrypted root-key envelope. A database-only attacker cannot derive the PRF result, portable private key, root key, record keys, or plaintext.

Configure and validate the RP

Passkey settings are deployment configuration. Set the three variables below. Ownfold validates them internally at server startup; the application does not install T3 Env or Valibot. Do not infer an RP ID or allow arbitrary request origins.

Variable Required value
OWNFOLD_WEBAUTHN_RP_ID Exact relying-party domain, such as app.example.com; no scheme or path.
OWNFOLD_WEBAUTHN_RP_NAME User-visible application name shown by the authenticator.
OWNFOLD_WEBAUTHN_ORIGINS Comma-separated exact HTTPS origin allowlist. Use an explicit localhost origin only in development.

See Environment configuration for deployment rules.

Server coordination

Create a PasskeyVaultServer with a VaultAdapter & PasskeyVaultAccessAdapter. Challenge storage must be durable and atomically consumable. Access-record creation must reject duplicate credential IDs and stale vault revisions. The official SQLite, direct PostgreSQL, Drizzle, and Prisma adapters implement this contract. Apply their passkey schema (0005_ownfold_passkey_access.sql for PostgreSQL/Drizzle, or the current Prisma model fragment) before enabling these routes.

import { createPasskeyVaultServerFromEnvironment } from "@ownfold/server"

const passkeys = createPasskeyVaultServerFromEnvironment({
  adapter,
  getUserId,
  verifyDeviceEnrollmentAuthorization: async (input) =>
    verifyHostDeviceEnrollmentProof(input),
})

verifyDeviceEnrollmentAuthorization is a required host security boundary. It must verify a fresh, replay-resistant cryptographic proof that the active input.authorizingDevice approved this exact enrollment request. A valid application session, possession of the device ID, or return true is not sufficient. The callback must return false or throw when the proof is missing, stale, replayed, or invalid; Ownfold then creates no challenge. The stored Ownfold device key is X25519 encryption material, not a signing key, so the host must supply this proof using its existing device-authentication mechanism.

The lifecycle operations are:

  • beginPasskeyEnrollment() and completePasskeyEnrollment();
  • continuePasskeyEnrollment() and completePasskeyEnrollmentAssertion() when registration reports PRF support without returning its first result;
  • beginPasskeyUnlock() and completePasskeyUnlock();
  • listPasskeyAccessMethods() and revokePasskeyAccessMethod().

Enrollment requires the authenticated user’s active authorizing device, exact vault revision, and the host-verified device proof above. The browser-side caller must also hold the live root-key handle to create the portable key. Every ceremony requires user verification, an exact RP ID and allowed origin, and a one-time five-minute challenge bound to user, vault, operation, and revision.

Browser ceremony

Use registerPasskeyVaultAccessWithPrf() for enrollment. It performs a follow-up assertion when registration returns no PRF result. authenticatePasskeyVaultAccess() performs unlock assertions. Both helpers explicitly replace clientExtensionResults with an empty object before returning the server payload.

Enrollment must start from a ready-unlocked client. After WebAuthn registration returns, use the local PRF result to create the portable-device artifacts, then send only the sanitized response and encrypted artifacts to the server:

const ceremony = await registerPasskeyVaultAccessWithPrf(options, continueEnrollment)
if (ceremony.status === "error") return ceremony

const artifacts = await vault.createPasskeyPortableDevice({
  ownerId: authenticatedUserId,
  credentialId: ceremony.value.response.id,
  portableDeviceId: crypto.randomUUID(),
  rpId: options.rp.id,
  prfInput: options.extensions.prf.eval.first,
  prfOutput: ceremony.value.prfOutput,
})
if (artifacts.status === "error") return artifacts

// ceremony.value.response contains no PRF result. artifacts contain ciphertext only.
await completeEnrollment({
  response: ceremony.value.response,
  portableKey: artifacts.value.portableKey,
  rootKeyEnvelope: artifacts.value.rootKeyEnvelope,
})

createPasskeyPortableDevice() rejects locked or unavailable states and always zeroes the supplied PRF buffer before returning.

After completePasskeyUnlock() returns the encrypted access record, pass its portableKey, rootKeyEnvelope, and the local PRF output to VaultClient.restoreWithPasskey(). The method opens the root key locally, registers and persists a normal browser device, wipes the supplied PRF buffer, and transitions to ready-unlocked. Reloads use ordinary local-device unlock and do not prompt for the passkey.

Rotation and revocation

During root-key rotation, list every active passkey method and wrap the replacement root key for each portablePublicKey, using portableDeviceId as the device-envelope recipient ID. Commit those replacement envelopes atomically with the vault version change. No PRF result is needed. Treat missing, duplicate, or malformed active recipients as a rotation conflict; revoked methods must not receive new envelopes.

Pass listPasskeyAccessMethods() results into the browser rotation operation. Every official adapter checks the exact active recipient set and expected revisions inside the same transaction as the normal-device and vault updates; a stale or missing passkey recipient rolls back the full cutover.

Revoking a passkey method blocks later server-assisted unlocks. It does not revoke normal devices previously enrolled through that method and cannot erase keys already captured by a compromised, unlocked device.

Fallbacks and provider behavior

The Recovery Kit remains the emergency fallback established during onboarding, and trusted-device pairing remains the fallback when another device is unlocked. If WebAuthn or PRF is unavailable, offer those paths. Never fall back to a short password, a localStorage key, a server-held plaintext key, or an empty-vault success state.

Synced passkeys can work across Apple, Google, and Windows ecosystems, but PRF availability varies by authenticator and cross-device path and must be detected at runtime. A WorkOS passkey login may therefore be followed by a second Ownfold biometric prompt on a new device. Hosted authentication providers do not expose their PRF result to Ownfold.

Before production rollout, manually exercise the actual supported combinations: Apple iCloud Keychain/Safari, Google Password Manager/Chrome, and Windows Hello/Edge. Cover same-device, synced-device, cancellation, and unavailable-PRF paths. Runtime feature detection remains required; passing one ecosystem does not prove another authenticator returns PRF output.

The cryptographic construction, server-visible metadata, and rejected alternatives are recorded in ADR 0011: Passkey vault access.

Last updated on August 7, 2026

Was this page helpful?