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,
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()andVaultClient.createDeviceApprovalProof();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 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.