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:
| Job | Package | Purpose |
|---|---|---|
| Login | @better-auth/passkey via enhancePasskey(passkey, …) | Sign the user into a Better Auth session (L0) |
| Unlock | browser crypto config + wrap slots | Derive 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
| Surface | API | Holds |
|---|---|---|
| Server | enhancePasskey(passkey, { rpID, origin, assertCredentialAccess, … }) | Counter guard + hardened stock composition |
| Browser | defineZeroAccessPasskeyCryptoConfig({ 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)
- Merge PRF extensions into WebAuthn request options
navigator.credentials.get/ authenticator UIextractPrfSecret(credential)- On empty/missing PRF →
PrfUnavailableError— degrade to vault password or recovery UX unlockMekWithPrforcreateVaultClient().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-slotsOptional 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.
Related
- WebAuthn and PRF — extended reading
- Crypto primitives
- How it works
- Vault guide
- Elevate / sudo