Zero Accessby Railmandocs

Passkeys and PRF

Use WebAuthn PRF so a passkey can unlock the vault without sending secrets to the server.

Passkeys and PRF

There are two different passkey jobs in this stack:

JobPackagePurpose
Login@better-auth/passkey via enhancePasskey(passkey, …)Sign the user into a Better Auth session (L0)
Unlockbrowser crypto config + wrap slotsDerive a PRF secret and unwrap the MEK (L2)

Do not delete encryption wraps when a login passkey is removed (and vice versa).

Server vs browser

SurfaceAPIHolds
ServerenhancePasskey(passkey, { rpID, origin, assertCredentialAccess, … })Counter guard + hardened stock composition
BrowserdefineZeroAccessPasskeyCryptoConfig({ salt })Tenant salt, suite, PRF helpers

Wire the same stack into login and elevate:

...passkeyStack.plugins,
elevate({ passkeyCounterGuard: passkeyStack.controller, ... }),

Domain separation

PRF/HKDF info strings bind purpose and user:

prefix | purpose | userId | [resourceId] | [suffix]

userId is required. Purposes such as storage_kek and elevate must not be interchangeable.

Configure a tenant salt in the browser:

import { defineZeroAccessPasskeyCryptoConfig } from "@railman/auth-zero-access-passkey/client";

const cryptoConfig = defineZeroAccessPasskeyCryptoConfig({
  salt: process.env.NEXT_PUBLIC_PRF_SALT!,
});

Ceremony (client)

  1. Merge PRF extensions into WebAuthn request options
  2. navigator.credentials.get / authenticator UI
  3. extractPrfSecret(credential)
  4. On empty/missing PRF → PrfUnavailableError — degrade to vault password or recovery UX
  5. unlockMekWithPrf or createVaultClient().unlockDaily
import {
  buildPrfGetExtensions,
  extractPrfSecret,
  defineZeroAccessPasskeyCryptoConfig,
} from "@railman/auth-zero-access-passkey/client";

const cryptoConfig = defineZeroAccessPasskeyCryptoConfig({
  salt: process.env.NEXT_PUBLIC_PRF_SALT!,
});

const extensions = await buildPrfGetExtensions({
  purpose: "storage_kek",
  userId,
  resourceId: mekId,
  cryptoConfig,
});

// ... after get ...
const prfSecret = extractPrfSecret(credential);

Pass the same config into product vault:

createVaultClient({ userId, passkeyOptions: cryptoConfig });

Wrap slot

After unlock, grant a durable wrap so the next visit can unlock without the recovery phrase:

// via zero-vault reenrollDailyMethods or grantPrfWrapSlot
// then POST the slot to /zero-access/wrap-slots

Optional encryptionCredentialId ties the wrap to a specific authenticator.

Elevate passkeys

Step-up can also use passkeys, but that path only mints an elevate claim (L1). It never receives the storage PRF secret for MEK unwrap. Replay protection uses the composition's counter controller — not Better Auth's stock counter column alone.

On this page