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.
rpIdstring
stringrpNamestring
stringoriginsreadonly [string, ...string[]]
readonly [string, ...string[]]runtimeEnv?EnvironmentSource
EnvironmentSourceadapterVaultAdapter & PasskeyVaultAccessAdapter
VaultAdapter & PasskeyVaultAccessAdaptergetUserId(input: { readonly request: Request }) => Promise<string | null>
(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.
(
input: VerifyDeviceEnrollmentAuthorizationInput,
) => Promise<boolean>now?() => Date
() => DatechallengeTtlMs?number
numberUse createPasskeyVaultServer(options) only when already-validated typed configuration comes from a
source other than environment variables.
adapterVaultAdapter & PasskeyVaultAccessAdapter
VaultAdapter & PasskeyVaultAccessAdaptergetUserId(input: { readonly request: Request }) => Promise<string | null>
(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.
(
input: VerifyDeviceEnrollmentAuthorizationInput,
) => Promise<boolean>rpIdstring
stringrpNamestring
stringallowedOriginsreadonly [string, ...string[]]
readonly [string, ...string[]]now?() => Date
() => DatechallengeTtlMs?number
numberrequestRequest
RequestuserIdstring
stringvaultIdstring
stringvaultRevisionnumber
numberauthorizingDeviceDeviceRecord
DeviceRecordThe 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. |
requestRequest
RequestauthorizingDeviceIdstring
stringexpectedVaultRevisionnumber
numberrequestRequest
RequestchallengeIdstring
stringresponseRegistrationResponseJSON
The PRF result must be removed from `clientExtensionResults` before transport.
RegistrationResponseJSONprfResultAvailableboolean
booleanaccessIdstring
stringportableKeyPasskeyPortableDeviceKeyV1
PasskeyPortableDeviceKeyV1rootKeyEnvelopeDeviceEnvelopeV1
DeviceEnvelopeV1requestRequest
RequestchallengeIdstring
stringresponseAuthenticationResponseJSON
The PRF result must be removed from `clientExtensionResults` before transport.
AuthenticationResponseJSONexpectedRevisionnumber
numberEvery 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. |
responseAwaited<ReturnType<typeof startRegistration>>
Awaited<ReturnType<typeof startRegistration>>prfOutputUint8Array
Uint8ArrayassertionResponse?Awaited<ReturnType<typeof startAuthentication>>
Awaited<ReturnType<typeof startAuthentication>>responseAwaited<ReturnType<typeof startAuthentication>>
Awaited<ReturnType<typeof startAuthentication>>prfOutputUint8Array
Uint8ArrayThe 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.
portableKeyPasskeyPortableDeviceKeyV1
PasskeyPortableDeviceKeyV1rootKeyEnvelopeDeviceEnvelopeV1
DeviceEnvelopeV1On 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:
createPasskeyPortableDevice(input: CreatePasskeyPortableDeviceInput) => Promise<Result$1<CreatedPasskeyPortableDevice, CryptoError>>
(input: CreatePasskeyPortableDeviceInput) => Promise<Result$1<CreatedPasskeyPortableDevice, CryptoError>>openPasskeyPortableDevice(input: OpenPasskeyPortableDeviceInput) => Promise<Result$1<RootKeyHandle, CryptoError>>
(input: OpenPasskeyPortableDeviceInput) => Promise<Result$1<RootKeyHandle, CryptoError>>format"ownfold.passkey-portable-device-key"
"ownfold.passkey-portable-device-key"version1
1algorithm"X25519"
"X25519"vaultIdstring
stringownerIdstring
stringcredentialIdstring
stringportableDeviceIdstring
stringportablePublicKeystring
stringrootKeyVersionnumber
Root-key version at enrollment; immutable because this metadata authenticates the portable key.
numberrpIdstring
stringcreatedAtstring
stringprf{
readonly input: string
}
{
readonly input: string
}kdf{
readonly algorithm: "HKDF-SHA-256"
readonly salt: string
readonly info: "ownfold.passkey-portable-device-key/v1"
readonly outputLength: 32
}
{
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
}
{
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.
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.
(input: CreatePasskeyChallengeInput) => Promise<Result$1<PasskeyChallengeRecord, StorageAdapterError>>consumePasskeyChallenge(input: ConsumePasskeyChallengeInput) => Promise<Result$1<PasskeyChallengeRecord | null, StorageAdapterError>>
(input: ConsumePasskeyChallengeInput) => Promise<Result$1<PasskeyChallengeRecord | null, StorageAdapterError>>createPasskeyVaultAccess(input: CreatePasskeyVaultAccessInput) => Promise<Result$1<PasskeyVaultAccessRecord, StorageAdapterError | ConflictError>>
(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>>
(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>>
(input: { readonly userId: string; readonly vaultId: string; }) => Promise<Result$1<readonly PasskeyVaultAccessRecord[], StorageAdapterError>>touchPasskeyVaultAccess(input: TouchPasskeyVaultAccessInput) => Promise<Result$1<PasskeyVaultAccessRecord, StorageAdapterError | ConflictError>>
(input: TouchPasskeyVaultAccessInput) => Promise<Result$1<PasskeyVaultAccessRecord, StorageAdapterError | ConflictError>>revokePasskeyVaultAccess(input: RevokePasskeyVaultAccessInput) => Promise<Result$1<PasskeyVaultAccessRecord, StorageAdapterError | ConflictError>>
(input: RevokePasskeyVaultAccessInput) => Promise<Result$1<PasskeyVaultAccessRecord, StorageAdapterError | ConflictError>>userIdstring
stringcredentialPublicKeystring
stringcredentialAlgorithmnumber
numbersignatureCounternumber
numberaaguid?string
stringportableKeyPasskeyPortableDeviceKeyV1
PasskeyPortableDeviceKeyV1rootKeyEnvelopeDeviceEnvelopeV1
DeviceEnvelopeV1idstring
stringvaultIdstring
stringcredentialIdstring
stringrpIdstring
stringregisteredOriginstring
stringtransportsreadonly PasskeyCredentialTransport[]
readonly PasskeyCredentialTransport[]backupEligibleboolean
booleanbackedUpboolean
booleanportableDeviceIdstring
stringportablePublicKeystring
stringrootKeyVersionnumber
Current version of `rootKeyEnvelope`; advances atomically during root-key rotation.
numbercreatedAtstring
stringlastUsedAt?string
stringrevokedAt?string
stringrevisionnumber
numberServer 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.