Skip to main content
This page contains the complete BioKey protocol specification. Any implementation conforming to this specification is BioKey-compatible.
Version: 0.2.0 (Draft)
Status: Work in Progress
License: CC0 1.0 Universal (Public Domain)

Abstract

BioKey is an open protocol for deriving a stable cryptographic identity from a biometric signal captured via a platform authenticator. It enables passwordless authentication without storing biometric data on any server, and without delegating identity custody to a device vendor (Apple, Google, Microsoft, etc.). This specification defines the enrollment handshake, key derivation standard, authentication flow, and challenge/response format.

Terminology

TermDefinition
Identity KeyA 256-bit value derived from the enrollment credential. Serves as the user’s public identity.
CredentialA WebAuthn PublicKeyCredential returned by navigator.credentials.create() or .get().
rawIdThe raw byte identifier of a WebAuthn credential. Used as V1 HKDF keying material.
PRFWebAuthn pseudo-random function extension (defined in WebAuthn Level 3). Produces a hardware-backed deterministic secret output per credential.
rpIdRelying Party ID. Must match the hostname of the origin.
ChallengeA 32-byte random nonce issued by the server. Used once. Expires after 5 minutes.
EnrollmentThe process of registering a biometric and deriving an Identity Key.
AuthenticationThe process of proving possession of the enrolled biometric to obtain a verified session.
Platform AuthenticatorA biometric sensor built into the device (fingerprint, Face ID, Windows Hello).
methodEither prf (V2, preferred) or rawid (V1, fallback). Recorded at enrollment and stored with the identity.

Key Derivation Standard

BioKey v0.2 defines two derivation paths. Implementations MUST attempt V2 first and fall back to V1 if the platform does not support the PRF extension.

V2 — WebAuthn PRF Extension (Preferred)

The WebAuthn PRF extension (defined in WebAuthn Level 3) allows a platform authenticator to produce a deterministic, hardware-backed symmetric key tied to a passkey. The output is secret and never exposed outside the authenticator.

Enrollment (navigator.credentials.create)

Pass the PRF extension with a fixed salt in the eval field:
extensions: {
  prf: { eval: { first: PRF_SALT } }
}
If the authenticator supports PRF, credential.getClientExtensionResults().prf.results.first contains a 32-byte ArrayBuffer. This is the Identity Key.

Authentication (navigator.credentials.get)

Pass the PRF extension via evalByCredential, keyed by the stored credentialId:
extensions: {
  prf: {
    evalByCredential: {
      [credentialId]: { first: PRF_SALT }
    }
  }
}
The authenticator re-derives the same 32-byte secret. The client MUST compare it against the stored publicKey and reject if they differ.

PRF Salt

ParameterValue
PRF_SALTUTF-8 encoding of "biokey-prf-v2-salt"
The salt is version-locked. Changing it produces a different Identity Key.

V1 — rawId + HKDF (Fallback)

Security note: rawId is a credential identifier, not a secret value. It may be observed by the server during enrollment and authentication. The V1 derivation path provides a stable identity seed for environments without PRF support, but it does not carry the same security guarantees as V2. Prefer V2 wherever available.

Algorithm

HKDF as defined in RFC 5869, using SHA-256.

Parameters

ParameterValue
HashSHA-256
IKM (Input Keying Material)credential.rawId bytes
SaltUTF-8 encoding of "biokey-v1-salt"
InfoUTF-8 encoding of "biokey-identity-seed"
L (Output Length)32 bytes (256 bits)

Output

A 32-byte (256-bit) value, hex-encoded as a 64-character lowercase string.

Reference Implementation

async function deriveKey(rawId) {
  const keyMaterial = await crypto.subtle.importKey(
    'raw', rawId, { name: 'HKDF' }, false, ['deriveBits']
  )
  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)
}

Enrollment Flow

Client                                    Server
  |                                          |
  |-- navigator.credentials.create() -----> |  (platform authenticator + PRF attempted)
  |<-- PublicKeyCredential -----------------||
  |                                          |
  |  IF prf.results.first present:           |
  |    publicKey = hex(prf.results.first)    |
  |    method = 'prf'                        |
  |  ELSE:                                   |
  |    publicKey = hex(HKDF(rawId))          |
  |    method = 'rawid'                      |
  |                                          |
  |-- POST /enroll ------------------------->|
  |   { userId, publicKey, deviceId, method }|
  |<-- { ok: true, userId, publicKey, method}|

Steps

  1. Client generates a random 32-byte challenge and 16-byte userId.
  2. Client calls navigator.credentials.create() with:
    • authenticatorAttachment: 'platform'
    • userVerification: 'required'
    • rp.id set to the current hostname
    • extensions.prf.eval.first set to PRF_SALT
  3. Platform authenticator is triggered. User provides biometric.
  4. If prf.results.first is present: Identity Key = hex(prf.results.first), method = 'prf'.
  5. Otherwise: Identity Key = hex(HKDF(rawId)), method = 'rawid'.
  6. Client stores { credentialId, publicKey, deviceId, enrolledAt, method } locally.
  7. If a server is present: client sends POST /enroll.

Implementation Example

// From packages/biokey-core/src/enroll.js
export async function enroll(rpId, rpName = 'BioKey') {
  const challenge = crypto.getRandomValues(new Uint8Array(32))
  const userId = crypto.getRandomValues(new Uint8Array(16))

  const credential = await navigator.credentials.create({
    publicKey: {
      challenge,
      rp: { name: rpName, id: rpId },
      user: {
        id: userId,
        name: 'biokey-user',
        displayName: 'BioKey User'
      },
      pubKeyCredParams: [
        { alg: -7, type: 'public-key' },   // ES256
        { alg: -8, type: 'public-key' },   // EdDSA
        { alg: -257, type: 'public-key' }  // RS256
      ],
      authenticatorSelection: {
        authenticatorAttachment: 'platform',
        userVerification: 'required',
        residentKey: 'preferred'
      },
      extensions: {
        prf: { eval: { first: PRF_SALT } }
      },
      timeout: 60000
    }
  })

  const prfOutput = extractPRFOutput(credential)
  let publicKey, method

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

  const credentialId = bufToHex(credential.rawId)

  return { publicKey, credentialId, enrolledAt: Date.now(), method }
}

Authentication Flow

Client                                    Server
  |                                          |
  |-- GET /challenge ------------------------>|
  |<-- { challenge: hex(32 bytes) } ---------|
  |                                          |
  |-- navigator.credentials.get() ---------> |  (PRF evalByCredential attempted)
  |<-- PublicKeyCredential -------------------|
  |                                          |
  |  IF prf.results.first present:           |
  |    derivedKey = hex(prf.results.first)   |
  |    assert derivedKey === stored.publicKey |
  |  ELSE:                                   |
  |    derivedKey = hex(HKDF(assertion.rawId))|
  |    assert derivedKey === stored.publicKey |
  |                                          |
  |-- POST /verify -------------------------->|
  |   { userId, challenge }                  |
  |<-- { verified: true, publicKey, method } |

Steps

  1. Client fetches a fresh challenge from GET /challenge.
  2. Client calls navigator.credentials.get() with stored credentialId and extensions.prf.evalByCredential.
  3. Platform authenticator is triggered. User provides biometric.
  4. Client re-derives Identity Key (PRF or rawId-HKDF) and compares against stored publicKey. Rejects on mismatch.
  5. Client sends POST /verify with userId and challenge hex.
  6. Server validates challenge (single-use, 5-minute TTL) and returns { verified: true, publicKey, method }.

Implementation Example

// From packages/biokey-core/src/authenticate.js
export async function authenticate(identity, rpId) {
  if (!identity?.credentialId) throw new Error('No enrolled identity provided.')

  const challenge = crypto.getRandomValues(new Uint8Array(32))
  const credId = hexToBuf(identity.credentialId)

  const assertion = await navigator.credentials.get({
    publicKey: {
      challenge,
      rpId,
      allowCredentials: [{ id: credId, type: 'public-key' }],
      userVerification: 'required',
      extensions: {
        prf: {
          evalByCredential: {
            [identity.credentialId]: { first: PRF_SALT }
          }
        }
      },
      timeout: 60000
    }
  })

  if (!assertion) throw new Error('Authentication failed — no assertion returned.')

  const prfOutput = extractPRFOutput(assertion)

  if (prfOutput) {
    // PRF path — re-derive and verify against stored publicKey
    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 — rawId-HKDF re-derivation
  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: The client MUST verify that the re-derived key matches the stored publicKey before sending the challenge to the server. Skipping this verification opens the door to man-in-the-middle attacks and credential substitution.

Offline / Local-Only Authentication

If no server is configured, the client may authenticate locally by verifying the re-derived key matches the stored publicKey. No challenge verification is performed. Suitable for local device unlock only.

Challenge Format

Issuance

  • 32 bytes, cryptographically random
  • Hex-encoded (64 lowercase hex characters)
  • Stored server-side with creation timestamp
  • Expires: 5 minutes from issuance
  • Single-use: deleted on first verification attempt

Request / Response

GET /challenge
→ { "challenge": "a3f1c29e...d4" }

Server API

All endpoints accept and return application/json.

POST /enroll

Register a new identity. Request body:
{
  "userId": "string",
  "publicKey": "64-char hex string",
  "deviceId": "16-char hex string",
  "method": "prf" | "rawid"
}
Response (200):
{
  "ok": true,
  "userId": "string",
  "publicKey": "64-char hex string",
  "method": "prf" | "rawid"
}
Error responses:
  • 400: Invalid request body (missing fields, invalid publicKey format)
  • 409: User already enrolled (publicKey already exists)

GET /challenge

Issue a fresh challenge for authentication. Response (200):
{ "challenge": "64-char hex string" }

POST /verify

Verify an authentication challenge. Request body:
{
  "userId": "string",
  "challenge": "64-char hex string"
}
Response (200):
{
  "verified": true,
  "publicKey": "64-char hex string",
  "userId": "string",
  "method": "prf" | "rawid"
}
Error responses:
  • 401: Invalid or expired challenge
  • 404: Unknown userId (not enrolled)

Identity Format

The client stores identities locally in this format:
interface Identity {
  publicKey: string      // 64-char hex — derived Identity Key
  credentialId: string   // hex-encoded WebAuthn credential rawId
  deviceId: string       // 16-char hex — deterministic device fingerprint
  enrolledAt: number     // Unix timestamp (ms)
  method: 'prf' | 'rawid'  // derivation method used at enrollment
}
Example:
{
  "publicKey": "a3f1c29e8d7b4f2c1e9a6b8d4f7c2e1a9b6d3f8c4e7a2d9f1c8b5e3a7d2f9c4e1b",
  "credentialId": "7b8d4f2c1e9a6b8d4f7c2e1a",
  "deviceId": "d4f7c2e1a9b6d3f8",
  "enrolledAt": 1704067200000,
  "method": "prf"
}

Known Limitations

Cross-Sensor Variance

Different hardware sensors produce different rawId values and PRF outputs for the same finger. Each device enrollment produces a distinct Identity Key. The server may link multiple public keys to one user account.

PIN Fallback

WebAuthn permits the device PIN as a fallback authenticator. BioKey cannot enforce biometric-only via the WebAuthn API alone. V3 goal: Native Android app using BiometricPrompt with BIOMETRIC_STRONG to block PIN fallback.

PRF Platform Support (as of 2025)

PRF is well-supported on Android (Chrome). macOS and iOS support is available in Safari 18+ but remains inconsistent. The V1 rawId-HKDF fallback ensures BioKey works across all platforms while PRF coverage matures.

Irrevocable Biometric

A fingerprint cannot be changed if compromised. Implementations should consider multi-finger enrollment, liveness detection, and server-side revocation via public key deletion.

Versioning

VersionDerivationSalt / InfoStatus
v0rawId → HKDFbiokey-v0-salt / biokey-identity-keyDeprecated
v1rawId → HKDFbiokey-v1-salt / biokey-identity-seedFallback only
v2PRF extensionbiokey-prf-v2-saltCurrent (preferred)
Protocol stability: The V1 and V2 salt/info strings are frozen. Changing them would break compatibility with existing enrollments. Future versions (V3+) may introduce new derivation paths with different parameters, but V1 and V2 will remain supported for backward compatibility.

Security Model

What BioKey protects against

  • Credential theft via server breach (server holds only public keys)
  • Vendor lock-in (no iCloud/Google dependency)
  • Replay attacks (challenges are single-use, time-limited)
  • Cross-origin abuse (rpId is bound to the domain)

What BioKey does NOT protect against

  • Compromised device (if the device is owned, the platform authenticator is owned)
  • PIN fallback (WebAuthn permits PIN as authenticator fallback; the OS controls this)
  • Cross-sensor attacks (V1/V2 — same identity across different hardware sensors is not guaranteed)
  • Biometric compromise (fingerprints are irrevocable; liveness detection is recommended)

Trust Boundaries

Trusted (on device):
  • Platform authenticator
  • WebAuthn API + PRF extension
  • HKDF derivation (V1 fallback only)
  • Identity Key storage (localStorage)
Untrusted (server-side):
  • Only public keys stored
  • Challenge issuance and verification
  • No biometric data, ever

Conformance Requirements

For an implementation to be BioKey-compatible, it MUST:
  1. Attempt V2 (PRF) derivation first using prf.eval.first during enrollment
  2. Fall back to V1 (rawId-HKDF) if PRF is unavailable
  3. Use exact salt/info strings specified: biokey-prf-v2-salt, biokey-v1-salt, biokey-identity-seed
  4. Store method field ('prf' or 'rawid') with identity
  5. Verify re-derived key matches stored publicKey during authentication
  6. Use 32-byte cryptographically random challenges
  7. Enforce single-use, 5-minute expiration on challenges
  8. Hex-encode all binary data (Identity Key, challenge) as lowercase
  9. Set authenticatorAttachment: 'platform' and userVerification: 'required'
  10. Bind rp.id to the application’s domain
Implementations MAY:
  • Add additional fields to the identity object (e.g., displayName, email)
  • Implement custom device fingerprinting beyond the 16-char deviceId
  • Support offline/local-only authentication (no server challenge)
  • Link multiple Identity Keys to one user account (multi-device)
  • Implement additional security measures (device attestation, anomaly detection)

Reference Implementation

The canonical BioKey implementation is available at:
  • Core library: packages/biokey-core/ (enrollment, authentication, key derivation)
  • Server: packages/biokey-server/ (Bun + Hono + SQLite)
  • Browser SDK: packages/biokey-js/ (client-side integration)
  • React hooks: packages/biokey-react/ (useBioKey hook)
Source code: Cryptoistaken/BioAuth

Authors

BioKey Protocol — open standard, not owned by any company.
Originated by Md Ratul Islam, 2025.
This specification is released into the public domain under CC0 1.0. No permission required to implement, fork, or build upon it.

Next Steps

How It Works

Understand the biometric-to-identity flow

Quick Start

Implement BioKey in your application

Build docs developers (and LLMs) love