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:
- Host authentication identifies the vault owner.
- A dedicated Ownfold WebAuthn credential returns a PRF secret only to the browser.
- That secret decrypts a portable X25519 private key locally.
- The portable key opens its root-key envelope locally.
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()andcompletePasskeyEnrollment();continuePasskeyEnrollment()andcompletePasskeyEnrollmentAssertion()when registration reports PRF support without returning its first result;beginPasskeyUnlock()andcompletePasskeyUnlock();listPasskeyAccessMethods()andrevokePasskeyAccessMethod().
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.