Skip to content
Ownfold
Esc
↑↓navigate↵open⌘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,
  getPasskeyUserInfo: async () => {
    const session = await verifiedSession()
    return {
      userName: session.user.email,
      userDisplayName: session.user.name,
    }
  },
})

getPasskeyUserInfo is optional host-owned display metadata for the browser and password manager. Use a recognizable account name such as the verified email address and the user’s display name. Ownfold still binds the credential to the immutable authenticated user ID and derives a stable, opaque 32-byte WebAuthn user handle from it; display labels never become authorization input.

Ownfold supplies the device-possession boundary. beginDeviceApproval() creates a persisted, one-time X25519 challenge for the active device. VaultClient.createDeviceApprovalProof() derives a challenge authenticator with the protected local device key. beginPasskeyEnrollment() consumes and verifies that proof before creating a WebAuthn challenge. The proof is bound to owner, vault, revision, device, challenge, and expiry. A host session or knowledge of a device ID cannot replace it. The optional verifyDeviceEnrollmentAuthorization callback remains only for applications that need an additional authorization gate. It runs only after the built-in proof succeeds and cannot replace or bypass that proof.

The lifecycle operations are:

  • beginDeviceApproval() and VaultClient.createDeviceApprovalProof();
  • 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 one-time 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 the official Fetch transport so the application does not recreate parsing or error mapping:

import { createFetchPasskeyVaultTransport } from "@ownfold/fetch"

const passkeys = createFetchPasskeyVaultTransport()
const challenge = await passkeys.beginDeviceApproval({
  authorizingDeviceId: currentDeviceId,
  expectedVaultRevision: vault.state.vault.revision,
})
if (challenge.status === "error") return challenge

const proof = await vault.createDeviceApprovalProof(challenge.value)
if (proof.status === "error") return proof

const enrollment = await passkeys.beginPasskeyEnrollment({
  authorizingDeviceId: currentDeviceId,
  expectedVaultRevision: challenge.value.vaultRevision,
  authorizationProof: proof.value,
})

Use registerPasskeyVaultAccessWithPrf() for enrollment. It performs a follow-up assertion when registration returns no PRF result. authenticatePasskeyVaultAccess() performs unlock assertions. The server options carry the PRF input as base64url JSON. Pass those options directly to the helpers: they decode the input to the ArrayBuffer required by WebAuthn immediately before the ceremony and reject malformed or non-32-byte inputs without invoking the authenticator. Both helpers explicitly replace clientExtensionResults with an empty object before returning the server payload. They preserve NotAllowedError and AbortError names from SimpleWebAuthn wrappers so cancellation and timeout remain actionable typed failures. A credential rejected through excludeCredentials returns PASSKEY_ALREADY_REGISTERED instead of a generic capability error.

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. It also cannot delete the credential from Apple Passwords, Google Password Manager, Windows, or another authenticator. Revoked credentials are not excluded from a fresh enrollment; Ownfold’s stable user handle lets supporting authenticators replace the old credential. If the authenticator retains it and refuses replacement, remove the old passkey there or choose a different authenticator.

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 8, 2026

Was this page helpful?