Skip to main content
BioKey derives a deterministic 256-bit Identity Key from biometric authentication using one of two methods: WebAuthn PRF extension (V2) or rawId-HKDF (V1).

Why Key Derivation?

Traditional biometric systems work like this:
Biometric → Unlocks → Pre-generated key (stored on device or in cloud)
BioKey inverts this model:
Biometric → Derives → Cryptographic identity (deterministic, reproducible)
Benefits:
  • No key storage in vendor clouds (iCloud, Google)
  • Same biometric on different devices can derive different keys (device-specific)
  • Identity tied to biological authentication, not device possession
  • Server stores only public keys, never secrets

V2: WebAuthn PRF Extension (Preferred)

The Pseudo-Random Function (PRF) extension is part of the WebAuthn Level 3 specification. It allows a platform authenticator to produce a deterministic, hardware-backed secret output tied to a passkey.

How PRF Works

Platform Authenticator (secure enclave)
  |
  ├─ Biometric verification (fingerprint, Face ID)
  ├─ Credential private key (stored in secure element)
  ├─ PRF salt input: "biokey-prf-v2-salt"
  └─ PRF function (hardware-backed cryptographic operation)
      └─ Output: 32 bytes (256 bits) deterministic secret
Key properties:
  • Deterministic: Same credential + same salt → same output
  • Hardware-backed: Computed inside secure enclave, never exposed
  • Secret: Unlike rawId, the PRF output is cryptographically secret
  • Stable: Survives OS updates, device reboots

PRF During Enrollment

When creating a credential, pass the PRF extension with a fixed salt:
const credential = await navigator.credentials.create({
  publicKey: {
    challenge: crypto.getRandomValues(new Uint8Array(32)),
    rp: { name: 'BioKey', id: 'example.com' },
    user: {
      id: crypto.getRandomValues(new Uint8Array(16)),
      name: 'biokey-user',
      displayName: 'BioKey User'
    },
    pubKeyCredParams: [
      { alg: -7, type: 'public-key' },   // ES256
      { alg: -257, type: 'public-key' }  // RS256
    ],
    authenticatorSelection: {
      authenticatorAttachment: 'platform',
      userVerification: 'required',
      residentKey: 'preferred'
    },
    extensions: {
      prf: {
        eval: {
          first: new TextEncoder().encode('biokey-prf-v2-salt')
        }
      }
    },
    timeout: 60000
  }
})
Extract the PRF output:
const prfResults = credential.getClientExtensionResults()?.prf?.results

if (prfResults?.first) {
  const identityKey = new Uint8Array(prfResults.first)
  const publicKey = bufToHex(identityKey) // 64-char hex string
  console.log('Identity Key:', publicKey)
  // method = 'prf'
}

PRF During Authentication

When authenticating, use evalByCredential to specify which credential should compute the PRF:
const assertion = await navigator.credentials.get({
  publicKey: {
    challenge: crypto.getRandomValues(new Uint8Array(32)),
    rpId: 'example.com',
    allowCredentials: [{
      id: hexToBuf(credentialId),
      type: 'public-key'
    }],
    userVerification: 'required',
    extensions: {
      prf: {
        evalByCredential: {
          [credentialId]: {
            first: new TextEncoder().encode('biokey-prf-v2-salt')
          }
        }
      }
    },
    timeout: 60000
  }
})

const prfOutput = assertion.getClientExtensionResults()?.prf?.results?.first
const derivedKey = bufToHex(new Uint8Array(prfOutput))

// CRITICAL: Verify against stored public key
if (derivedKey !== storedIdentity.publicKey) {
  throw new Error('PRF key mismatch — identity verification failed')
}

PRF Salt Specification

ParameterValue
Salt string"biokey-prf-v2-salt"
EncodingUTF-8 bytes
Length20 bytes
Version lockChanging salt produces different Identity Key
Do not modify the PRF salt. The salt is version-locked in the protocol specification. Changing it will produce a completely different Identity Key, breaking existing enrollments.

Platform Support for PRF

As of 2025:
PlatformBrowserSupportNotes
AndroidChrome✅ ExcellentRecommended for production
macOSSafari 18+✅ GoodImproving with each release
iOSSafari 18+✅ GoodRequires iOS 18+
WindowsEdge⚠️ LimitedCheck PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
LinuxFirefox❌ Not yetFalls back to V1
Check PRF availability:
function isPRFSupported(credential) {
  return !!(
    credential?.getClientExtensionResults?.()?.prf?.enabled ||
    credential?.getClientExtensionResults?.()?.prf?.results?.first
  )
}

V1: rawId + HKDF (Fallback)

For platforms that don’t support PRF, BioKey falls back to deriving a key from the credential’s rawId using HKDF (HMAC-based Key Derivation Function) as defined in RFC 5869.

What is rawId?

When WebAuthn creates a credential, it returns a unique identifier called rawId:
const credential = await navigator.credentials.create({...})

// rawId is an ArrayBuffer, typically 16-64 bytes
console.log(credential.rawId) // ArrayBuffer(32) { ... }
Important characteristics:
  • Not secret: The rawId is a credential identifier, not a cryptographic secret
  • Observable: It’s transmitted to the server during enrollment and authentication
  • Stable: Remains constant for the lifetime of the credential
  • Device-specific: Different devices generate different rawId values for the same biometric
Security consideration: Because rawId is an identifier (not a secret), V1 derivation provides identity stability but not the same cryptographic guarantees as V2 PRF. An attacker observing the rawId could potentially derive the same Identity Key if they gain access to the protocol parameters.

HKDF Algorithm

HKDF expands pseudo-random keying material into a cryptographically strong key:
Input Keying Material (IKM): credential.rawId

  HKDF-Extract (with salt)

Pseudo-Random Key (PRK)

  HKDF-Expand (with info + length)

Output Key Material (OKM): 256-bit Identity Key

HKDF Parameters

ParameterValueDescription
HashSHA-256HMAC hash function
IKMcredential.rawIdInput keying material (raw bytes)
Salt"biokey-v1-salt"UTF-8 encoded, prevents rainbow tables
Info"biokey-identity-seed"UTF-8 encoded, application context
L256 bits (32 bytes)Output length

Implementation

BioKey uses the Web Crypto API’s HKDF implementation:
// From packages/biokey-core/src/derive.js
export async function deriveKey(rawId) {
  // Step 1: Import rawId as key material
  const keyMaterial = await crypto.subtle.importKey(
    'raw',
    rawId,
    { name: 'HKDF' },
    false,
    ['deriveBits']
  )

  // Step 2: Derive 256 bits using HKDF-SHA256
  const bits = await crypto.subtle.deriveBits(
    {
      name: 'HKDF',
      hash: 'SHA-256',
      salt: new TextEncoder().encode('biokey-v1-salt'),
      info: new TextEncoder().encode('biokey-identity-seed')
    },
    keyMaterial,
    256
  )

  return new Uint8Array(bits)
}
Usage:
const credential = await navigator.credentials.create({...})
const identityKey = await deriveKey(credential.rawId)
const publicKey = bufToHex(identityKey) // 64-char hex string

Why These Salt and Info Values?

Salt: "biokey-v1-salt"
  • Prevents rainbow table attacks
  • Version-locks the derivation (changing salt = different output)
  • Namespaced to BioKey protocol v1
Info: "biokey-identity-seed"
  • Binds the output to a specific application context
  • Prevents key confusion between different uses of the same rawId
  • Allows future protocol extensions with different info strings
Protocol versioning: The V1 salt and info strings are frozen in the specification. Changing them would break compatibility with existing enrollments. Future versions (V3+) may introduce new derivation paths with different parameters.

Automatic Method Selection

BioKey automatically attempts V2 (PRF) first and falls back to V1 (HKDF) if PRF is unsupported:
// From packages/biokey-core/src/enroll.js
export async function enroll(rpId, rpName = 'BioKey') {
  const credential = await navigator.credentials.create({
    publicKey: {
      // ... options ...
      extensions: {
        prf: { eval: { first: PRF_SALT } }
      }
    }
  })

  const prfOutput = extractPRFOutput(credential)
  let publicKey, method

  if (prfOutput) {
    // V2: PRF path — hardware-backed secret
    publicKey = bufToHex(prfOutput)
    method = 'prf'
  } else {
    // V1: rawId-HKDF fallback
    const seed = await deriveKey(credential.rawId)
    publicKey = bufToHex(seed)
    method = 'rawid'
  }

  return { publicKey, credentialId: bufToHex(credential.rawId), enrolledAt: Date.now(), method }
}
The method field is stored with the identity so authentication uses the same derivation path.

Key Verification During Authentication

After re-deriving the Identity Key during authentication, the client MUST verify it matches the stored public key:
// From packages/biokey-core/src/authenticate.js
const prfOutput = extractPRFOutput(assertion)

if (prfOutput) {
  const derivedKey = bufToHex(prfOutput)
  if (identity.method === 'prf' && derivedKey !== identity.publicKey) {
    throw new Error('PRF key mismatch — identity verification failed.')
  }
  return { verified: true, publicKey: derivedKey, method: 'prf' }
}

// V1 fallback
const seed = await deriveKey(assertion.rawId)
const derivedKey = bufToHex(seed)

if (identity.method === 'rawid' && derivedKey !== identity.publicKey) {
  throw new Error('Key mismatch — identity verification failed.')
}

return { verified: true, publicKey: identity.publicKey, method: 'rawid' }
Critical security check: This verification prevents:
  • Man-in-the-middle attacks
  • Credential substitution
  • Biometric spoofing attempts
  • Protocol downgrade attacks
Never skip this verification step.

Identity Key Format

Regardless of derivation method, the Identity Key is always:
  • Length: 256 bits (32 bytes)
  • Encoding: Hex string (64 lowercase characters)
  • Alphabet: 0-9a-f
  • Example: a3f1c29e8d7b4f2c1e9a6b8d4f7c2e1a9b6d3f8c4e7a2d9f1c8b5e3a7d2f9c4e1b
Utility functions:
// Buffer to hex
function bufToHex(buf) {
  return [...new Uint8Array(buf)]
    .map(b => b.toString(16).padStart(2, '0'))
    .join('')
}

// Hex to buffer
function hexToBuf(hex) {
  return new Uint8Array(
    hex.match(/.{2}/g).map(b => parseInt(b, 16))
  ).buffer
}

Cross-Device Considerations

Same Biometric, Different Devices

Each device generates a different credential and Identity Key for the same biometric:
Device A (iPhone):
  Fingerprint → credentialId_A → Identity Key A

Device B (Android):
  Same fingerprint → credentialId_B → Identity Key B

  Identity Key A ≠ Identity Key B
This is intentional:
  • Security: Device compromise doesn’t expose identity on other devices
  • Privacy: Prevents cross-device fingerprinting
  • Flexibility: User can enroll multiple devices to one account

Server-Side Linking

Servers can link multiple Identity Keys to one user account:
// Database schema
{
  userId: 'user-123',
  identities: [
    { publicKey: 'a3f1c...', deviceId: 'iphone-15', method: 'prf' },
    { publicKey: '7b8d4...', deviceId: 'pixel-8', method: 'rawid' }
  ]
}

Cryptographic Properties

Both V2 and V1 produce Identity Keys with these properties:
PropertyV2 (PRF)V1 (HKDF)
Deterministic✅ Yes✅ Yes
Entropy256 bits256 bits
Hardware-backed✅ Yes❌ No (Web Crypto API)
Secret output✅ Yes⚠️ Derived from observable rawId
Collision resistance✅ High✅ High (SHA-256)
Rainbow table attack✅ Immune✅ Immune (salted HKDF)

Next Steps

Security Model

Understand threat models and security boundaries

Protocol Specification

Full technical specification of the BioKey protocol

Build docs developers (and LLMs) love