Skip to content
Ownfold
Esc
navigateopen⌘Jpreview
On this page

Passkey vault access API

Exact browser, crypto, server, persistence, rotation, environment, and error contracts for Ownfold WebAuthn PRF vault access.

Passkey vault access lets an authenticated user recover an existing vault on a new browser without uploading a Recovery Kit. It uses a dedicated Ownfold WebAuthn PRF credential; an authentication passkey from Better Auth, Auth.js, WorkOS, or another provider does not supply Ownfold’s PRF output.

The server verifies WebAuthn and stores ciphertext plus public metadata. The browser alone receives the PRF output, opens the portable X25519 private key and root-key envelope, then enrolls itself as a normal local device.

Required packages

pnpm add @ownfold/browser@beta @ownfold/core@beta @ownfold/server@beta

Install one official database adapter. @ownfold/server uses SimpleWebAuthn and T3 Env internally; applications do not install either T3 Env or Valibot for this feature.

Package-owned environment configuration

Set these server variables:

Variable Contract
OWNFOLD_WEBAUTHN_RP_ID Hostname-only RP ID, such as app.example.com; no scheme, port, or path.
OWNFOLD_WEBAUTHN_RP_NAME Non-empty authenticator display name.
OWNFOLD_WEBAUTHN_ORIGINS Comma-separated exact HTTPS origins. Explicit localhost and loopback HTTP origins are accepted for development.
import { createPasskeyVaultServerFromEnvironment } from "@ownfold/server"

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

createPasskeyVaultServerFromEnvironment() calls readPasskeyEnvironment() and fails during construction when configuration is absent or malformed. Error messages name variables but never include their values.

PropType
rpIdstring
Typestring
rpNamestring
Typestring
originsreadonly [string, ...string[]]
Typereadonly [string, ...string[]]
PropType
runtimeEnv?EnvironmentSource
TypeEnvironmentSource
adapterVaultAdapter & PasskeyVaultAccessAdapter
TypeVaultAdapter & PasskeyVaultAccessAdapter
getUserId(input: { readonly request: Request }) => Promise<string | null>
Type(input: { readonly request: Request }) => Promise<string | null>
verifyDeviceEnrollmentAuthorization( input: VerifyDeviceEnrollmentAuthorizationInput, ) => Promise<boolean>

Verifies a fresh, device-authenticated proof before passkey enrollment starts. The host must bind the proof to this request and active device; session authentication alone is insufficient.

Type( input: VerifyDeviceEnrollmentAuthorizationInput, ) => Promise<boolean>
now?() => Date
Type() => Date
challengeTtlMs?number
Typenumber

Use createPasskeyVaultServer(options) only when already-validated typed configuration comes from a source other than environment variables.

PropType
adapterVaultAdapter & PasskeyVaultAccessAdapter
TypeVaultAdapter & PasskeyVaultAccessAdapter
getUserId(input: { readonly request: Request }) => Promise<string | null>
Type(input: { readonly request: Request }) => Promise<string | null>
verifyDeviceEnrollmentAuthorization( input: VerifyDeviceEnrollmentAuthorizationInput, ) => Promise<boolean>

Verifies a fresh, device-authenticated proof before passkey enrollment starts. The host must bind the proof to this request and active device; session authentication alone is insufficient.

Type( input: VerifyDeviceEnrollmentAuthorizationInput, ) => Promise<boolean>
rpIdstring
Typestring
rpNamestring
Typestring
allowedOriginsreadonly [string, ...string[]]
Typereadonly [string, ...string[]]
now?() => Date
Type() => Date
challengeTtlMs?number
Typenumber
PropType
requestRequest
TypeRequest
userIdstring
Typestring
vaultIdstring
Typestring
vaultRevisionnumber
Typenumber
authorizingDeviceDeviceRecord
TypeDeviceRecord

The verifier must authenticate a fresh, replay-resistant cryptographic proof from authorizingDevice and bind it to the exact enrollment request. Session authentication, an active device lookup, or knowledge of authorizingDeviceId alone is not authorization to add a recovery method. Return false or throw for a missing, stale, replayed, or invalid proof; the server fails closed before storing a challenge. Ownfold device public keys are X25519 encryption keys and cannot verify signatures, so applications must connect this callback to their own device-authentication mechanism. Never implement it as an unconditional true result.

Server lifecycle

PasskeyVaultServer exposes these operations:

Method Purpose and authorization
beginPasskeyEnrollment() Requires an authenticated owner, current vault revision, active authorizing device, and successful host verification of a fresh device proof. Creates a five-minute one-time registration challenge with user verification required and a random 32-byte PRF input.
completePasskeyEnrollment() Atomically consumes the challenge, verifies registration, validates PRF availability and portable-key context, then creates the access record at the bound vault revision.
continuePasskeyEnrollment() Verifies registration and returns assertion options when the authenticator reports PRF support without returning its first result.
completePasskeyEnrollmentAssertion() Verifies the continuation assertion and creates the access record.
beginPasskeyUnlock() Rejects missing or revoked credentials and returns an assertion restricted to the authenticated owner’s credential and stored PRF input.
completePasskeyUnlock() Atomically consumes and verifies the assertion, updates usage/counter metadata, and returns the encrypted access record.
listPasskeyAccessMethods() Returns only the authenticated owner’s public/ciphertext-safe summaries.
revokePasskeyAccessMethod() Uses the access-record revision to block future assisted unlocks. Normal devices previously enrolled through the method remain active.
PropType
requestRequest
TypeRequest
authorizingDeviceIdstring
Typestring
expectedVaultRevisionnumber
Typenumber
PropType
requestRequest
TypeRequest
challengeIdstring
Typestring
responseRegistrationResponseJSON

The PRF result must be removed from `clientExtensionResults` before transport.

TypeRegistrationResponseJSON
prfResultAvailableboolean
Typeboolean
accessIdstring
Typestring
portableKeyPasskeyPortableDeviceKeyV1
TypePasskeyPortableDeviceKeyV1
rootKeyEnvelopeDeviceEnvelopeV1
TypeDeviceEnvelopeV1
PropType
requestRequest
TypeRequest
challengeIdstring
Typestring
responseAuthenticationResponseJSON

The PRF result must be removed from `clientExtensionResults` before transport.

TypeAuthenticationResponseJSON
expectedRevisionnumber
Typenumber

Every registration or assertion response sent to these methods must have an empty clientExtensionResults object. A transported PRF result is rejected before persistence. Challenges are bound to user, vault, operation, revision, credential where applicable, and expiry; the first completion attempt consumes them even if later verification fails.

SimpleWebAuthn performs CBOR, COSE, signature, challenge, origin, RP ID, and user-verification checks. Ownfold additionally checks the portable-key/root-envelope authenticated context. Synced credentials may keep a zero counter; counter metadata is retained without incorrectly rejecting backup-eligible credentials.

Browser ceremonies

import {
  authenticatePasskeyVaultAccess,
  registerPasskeyVaultAccessWithPrf,
} from "@ownfold/browser"
Helper Result
registerPasskeyVaultAccess(options) Registration response stripped of extension results plus a local 32-byte PRF copy. Use only when the authenticator returns the first PRF result during registration.
registerPasskeyVaultAccessWithPrf(options, continueWithAssertion) Same result, with a follow-up assertion when registration did not return the first PRF result.
authenticatePasskeyVaultAccess(options) Sanitized assertion plus a local 32-byte PRF copy.
preparePasskeyRotationEnvelopes(input) Replacement root-key envelopes for every active portable recipient; revoked methods are excluded and no PRF ceremony runs.
PropType
responseAwaited<ReturnType<typeof startRegistration>>
TypeAwaited<ReturnType<typeof startRegistration>>
prfOutputUint8Array
TypeUint8Array
assertionResponse?Awaited<ReturnType<typeof startAuthentication>>
TypeAwaited<ReturnType<typeof startAuthentication>>
PropType
responseAwaited<ReturnType<typeof startAuthentication>>
TypeAwaited<ReturnType<typeof startAuthentication>>
prfOutputUint8Array
TypeUint8Array

The helpers return PASSKEY_UNSUPPORTED, cancellation, or PASSKEY_PRF_UNSUPPORTED errors without weakening the flow. Offer Recovery Kit restoration or trusted-device pairing instead.

Portable key creation and new-browser restore

Enrollment starts from ready-unlocked:

const portable = await vault.createPasskeyPortableDevice({
  ownerId: authenticatedUserId,
  credentialId: ceremony.value.response.id,
  portableDeviceId: crypto.randomUUID(),
  rpId: registrationOptions.rp.id,
  prfInput: registrationOptions.extensions.prf.eval.first,
  prfOutput: ceremony.value.prfOutput,
})

createPasskeyPortableDevice() generates X25519 keys, derives a 32-byte AES key with HKDF-SHA-256, encrypts the portable private key with AES-256-GCM and canonical authenticated data, and wraps the current root key for the portable public key. It always zeroes the supplied PRF buffer before returning.

PropType
portableKeyPasskeyPortableDeviceKeyV1
TypePasskeyPortableDeviceKeyV1
rootKeyEnvelopeDeviceEnvelopeV1
TypeDeviceEnvelopeV1

On a new browser, send the sanitized assertion to completePasskeyUnlock(), then pass its encrypted record and the local PRF output to:

await vault.restoreWithPasskey(
  {
    portableKey: access.portableKey,
    rootKeyEnvelope: access.rootKeyEnvelope,
  },
  ceremony.value.prfOutput,
)

restoreWithPasskey() is valid only from unavailable-on-device. It validates vault and key versions, opens both envelopes locally, creates and registers a normal browser device, persists its protected private key in the existing storage format, zeroes the PRF buffer, and transitions to ready-unlocked. Reload then uses ordinary local-device unlock.

Portable cryptography contract

CryptoEngine extends PasskeyPortableDeviceCrypto:

PropType
createPasskeyPortableDevice(input: CreatePasskeyPortableDeviceInput) => Promise<Result$1<CreatedPasskeyPortableDevice, CryptoError>>
Type(input: CreatePasskeyPortableDeviceInput) => Promise<Result$1<CreatedPasskeyPortableDevice, CryptoError>>
openPasskeyPortableDevice(input: OpenPasskeyPortableDeviceInput) => Promise<Result$1<RootKeyHandle, CryptoError>>
Type(input: OpenPasskeyPortableDeviceInput) => Promise<Result$1<RootKeyHandle, CryptoError>>
PropType
format"ownfold.passkey-portable-device-key"
Type"ownfold.passkey-portable-device-key"
version1
Type1
algorithm"X25519"
Type"X25519"
vaultIdstring
Typestring
ownerIdstring
Typestring
credentialIdstring
Typestring
portableDeviceIdstring
Typestring
portablePublicKeystring
Typestring
rootKeyVersionnumber

Root-key version at enrollment; immutable because this metadata authenticates the portable key.

Typenumber
rpIdstring
Typestring
createdAtstring
Typestring
prf{ readonly input: string }
Type{ readonly input: string }
kdf{ readonly algorithm: "HKDF-SHA-256" readonly salt: string readonly info: "ownfold.passkey-portable-device-key/v1" readonly outputLength: 32 }
Type{ readonly algorithm: "HKDF-SHA-256" readonly salt: string readonly info: "ownfold.passkey-portable-device-key/v1" readonly outputLength: 32 }
encryption{ readonly algorithm: "A256GCM" readonly nonce: string readonly ciphertext: string }
Type{ readonly algorithm: "A256GCM" readonly nonce: string readonly ciphertext: string }

The AES-GCM authenticated data binds format/version, vault and owner, credential and portable device IDs, portable public key, root-key version, RP ID, PRF input, KDF parameters, and creation time. Modifying any bound field, encrypted private key, or root-key envelope fails closed.

The server may store PasskeyPortableDeviceKeyV1; it must never receive the PRF result, decrypted portable private key, root key, Recovery Kit secret, record key, or plaintext record.

Persistence and atomic rotation

Official SQLite, PostgreSQL, Drizzle, and Prisma adapters implement PasskeyVaultAccessAdapter.

PropType
createPasskeyChallenge(input: CreatePasskeyChallengeInput) => Promise<Result$1<PasskeyChallengeRecord, StorageAdapterError>>

Stores one active challenge per user and operation. The write must atomically remove expired challenges and supersede the previous challenge for that user/operation before inserting.

Type(input: CreatePasskeyChallengeInput) => Promise<Result$1<PasskeyChallengeRecord, StorageAdapterError>>
consumePasskeyChallenge(input: ConsumePasskeyChallengeInput) => Promise<Result$1<PasskeyChallengeRecord | null, StorageAdapterError>>
Type(input: ConsumePasskeyChallengeInput) => Promise<Result$1<PasskeyChallengeRecord | null, StorageAdapterError>>
createPasskeyVaultAccess(input: CreatePasskeyVaultAccessInput) => Promise<Result$1<PasskeyVaultAccessRecord, StorageAdapterError | ConflictError>>
Type(input: CreatePasskeyVaultAccessInput) => Promise<Result$1<PasskeyVaultAccessRecord, StorageAdapterError | ConflictError>>
getPasskeyVaultAccess(input: { readonly userId: string; readonly vaultId: string; readonly credentialId: string; }) => Promise<Result$1<PasskeyVaultAccessRecord | null, StorageAdapterError>>
Type(input: { readonly userId: string; readonly vaultId: string; readonly credentialId: string; }) => Promise<Result$1<PasskeyVaultAccessRecord | null, StorageAdapterError>>
listPasskeyVaultAccess(input: { readonly userId: string; readonly vaultId: string; }) => Promise<Result$1<readonly PasskeyVaultAccessRecord[], StorageAdapterError>>
Type(input: { readonly userId: string; readonly vaultId: string; }) => Promise<Result$1<readonly PasskeyVaultAccessRecord[], StorageAdapterError>>
touchPasskeyVaultAccess(input: TouchPasskeyVaultAccessInput) => Promise<Result$1<PasskeyVaultAccessRecord, StorageAdapterError | ConflictError>>
Type(input: TouchPasskeyVaultAccessInput) => Promise<Result$1<PasskeyVaultAccessRecord, StorageAdapterError | ConflictError>>
revokePasskeyVaultAccess(input: RevokePasskeyVaultAccessInput) => Promise<Result$1<PasskeyVaultAccessRecord, StorageAdapterError | ConflictError>>
Type(input: RevokePasskeyVaultAccessInput) => Promise<Result$1<PasskeyVaultAccessRecord, StorageAdapterError | ConflictError>>
PropType
userIdstring
Typestring
credentialPublicKeystring
Typestring
credentialAlgorithmnumber
Typenumber
signatureCounternumber
Typenumber
aaguid?string
Typestring
portableKeyPasskeyPortableDeviceKeyV1
TypePasskeyPortableDeviceKeyV1
rootKeyEnvelopeDeviceEnvelopeV1
TypeDeviceEnvelopeV1
idstring
Typestring
vaultIdstring
Typestring
credentialIdstring
Typestring
rpIdstring
Typestring
registeredOriginstring
Typestring
transportsreadonly PasskeyCredentialTransport[]
Typereadonly PasskeyCredentialTransport[]
backupEligibleboolean
Typeboolean
backedUpboolean
Typeboolean
portableDeviceIdstring
Typestring
portablePublicKeystring
Typestring
rootKeyVersionnumber

Current version of `rootKeyEnvelope`; advances atomically during root-key rotation.

Typenumber
createdAtstring
Typestring
lastUsedAt?string
Typestring
revokedAt?string
Typestring
revisionnumber
Typenumber

Server methods return the exported PasskeyServerError union; PasskeyVaultServerError is its public compatibility alias, so consumers can narrow each tagged error member.

Apply migration 0005_ownfold_passkey_access.sql for direct PostgreSQL/Drizzle, the current SQLite schema, or the supplied Prisma migration before serving passkey operations.

Challenge consumption must be atomic. Creating a challenge removes expired rows and supersedes the previous challenge for the same user and operation, bounding abandoned ceremonies. Credential IDs are globally unique. Access creation, touch, and revocation use revision predicates. Root rotation must validate and replace the root envelope for every active passkey recipient in the same transaction as the vault, Recovery Kit metadata, normal device envelopes, and rotation completion. Missing, extra, duplicate, stale, cross-vault, or malformed active recipients abort the transaction and preserve the old key version.

portableKey.rootKeyVersion records the enrollment version and remains immutable because it is authenticated with the PRF-wrapped portable private key. The access record’s rootKeyVersion and rootKeyEnvelope.keyVersion advance together during rotation; unlock accepts that newer envelope without rewriting the portable key.

Errors

Expected failures are PasskeyVaultAccessError values in Result, including:

PASSKEY_UNSUPPORTED, PASSKEY_PRF_UNSUPPORTED, PASSKEY_REGISTRATION_CANCELLED, PASSKEY_ASSERTION_CANCELLED, PASSKEY_USER_VERIFICATION_REQUIRED, PASSKEY_ORIGIN_MISMATCH, PASSKEY_RP_ID_MISMATCH, PASSKEY_CHALLENGE_EXPIRED, PASSKEY_CHALLENGE_REPLAYED, PASSKEY_CREDENTIAL_NOT_FOUND, PASSKEY_CREDENTIAL_REVOKED, PASSKEY_ENVELOPE_CORRUPTED, and PASSKEY_REVISION_CONFLICT.

Use the code for UI behavior. Preserve the prior state, offer documented recovery fallbacks, and do not retry with relaxed origin, RP, user-verification, revision, or envelope checks.

See the integration guide, environment configuration, errors, database APIs, and testing APIs.

Last updated on August 7, 2026

Was this page helpful?