Skip to main content
This example demonstrates a complete full-stack biometric authentication system with client-side enrollment and server-side verification.

Architecture Overview

The full-stack BioKey system consists of:
  1. Client - React app using biokey-react for biometric enrollment and authentication
  2. Server - Node.js backend using biokey-server for challenge generation and verification
  3. Database - Simple SQLite storage for user identities (can be replaced with any DB)

Installation

Client Dependencies

npm install biokey-react biokey-js react react-dom

Server Dependencies

npm install hono better-sqlite3

Complete Implementation

Server Code

import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { initDB, saveIdentity, getIdentity, createChallenge, consumeChallenge } from './db.js'

const app = new Hono()

// Enable CORS for client requests
app.use('*', cors({
  origin: ['http://localhost:5173', 'http://localhost:3000'],
  credentials: true
}))

// Initialize database
initDB()

// Health check
app.get('/', (c) => {
  return c.json({ 
    name: 'biokey-server', 
    version: '1.0.0',
    endpoints: ['/enroll', '/challenge', '/verify']
  })
})

// Enroll new user
app.post('/enroll', async (c) => {
  const body = await c.req.json().catch(() => null)

  if (!body?.userId || !body?.publicKey || !body?.deviceId) {
    return c.json({ 
      error: 'Missing required fields: userId, publicKey, deviceId' 
    }, 400)
  }

  const { userId, publicKey, deviceId, method = 'prf' } = body

  // Validate public key format (64 hex chars = 32 bytes)
  if (typeof publicKey !== 'string' || publicKey.length !== 64) {
    return c.json({ error: 'Invalid publicKey format' }, 400)
  }

  try {
    saveIdentity(userId, publicKey, deviceId, method)
    console.log(`✓ Enrolled user: ${userId}`)
    return c.json({ 
      ok: true, 
      userId, 
      publicKey,
      method 
    })
  } catch (err) {
    console.error('Enrollment error:', err)
    return c.json({ 
      error: 'Enrollment failed', 
      detail: err.message 
    }, 500)
  }
})

// Generate authentication challenge
app.get('/challenge', (c) => {
  const challenge = createChallenge()
  console.log(`→ Generated challenge: ${challenge.slice(0, 16)}...`)
  return c.json({ challenge })
})

// Verify authentication
app.post('/verify', async (c) => {
  const body = await c.req.json().catch(() => null)

  if (!body?.userId || !body?.challenge) {
    return c.json({ 
      error: 'Missing required fields: userId, challenge' 
    }, 400)
  }

  const { userId, challenge } = body

  // Lookup user identity
  const identity = getIdentity(userId)
  if (!identity) {
    console.log(`✗ Unknown user: ${userId}`)
    return c.json({ error: 'Unknown userId' }, 404)
  }

  // Verify challenge is valid and not expired
  const valid = consumeChallenge(challenge)
  if (!valid) {
    console.log(`✗ Invalid/expired challenge for ${userId}`)
    return c.json({ error: 'Invalid or expired challenge' }, 401)
  }

  console.log(`✓ Verified user: ${userId}`)
  return c.json({ 
    verified: true, 
    publicKey: identity.public_key,
    userId,
    method: identity.method
  })
})

const port = process.env.PORT || 3000
console.log(`🚀 BioKey server running on http://localhost:${port}`)

export default {
  port,
  fetch: app.fetch
}

Client Code

import { useBioKey } from 'biokey-react'
import { useState, useEffect } from 'react'
import './App.css'

const SERVER_URL = 'http://localhost:3000'

function App() {
  const { 
    identity, 
    status, 
    error, 
    isEnrolled, 
    isLoading,
    enroll, 
    authenticate,
    reset
  } = useBioKey({
    rpName: 'BioKey Full-Stack Demo',
    serverUrl: SERVER_URL
  })

  const [userId, setUserId] = useState('[email protected]')
  const [serverStatus, setServerStatus] = useState('checking...')
  const [lastAuth, setLastAuth] = useState(null)

  // Check server health on mount
  useEffect(() => {
    fetch(SERVER_URL)
      .then(res => res.json())
      .then(data => setServerStatus(`✓ ${data.name} v${data.version}`))
      .catch(() => setServerStatus('✗ Server offline'))
  }, [])

  const handleEnroll = async () => {
    try {
      const result = await enroll(userId)
      console.log('Enrollment result:', result)
    } catch (err) {
      console.error('Enrollment failed:', err)
    }
  }

  const handleAuth = async () => {
    try {
      const result = await authenticate(userId)
      setLastAuth({
        time: new Date().toLocaleTimeString(),
        verified: result.verified,
        method: result.method
      })
      console.log('Authentication result:', result)
    } catch (err) {
      console.error('Authentication failed:', err)
      setLastAuth(null)
    }
  }

  const formatKey = (key) => {
    if (!key) return ''
    return key.match(/.{8}/g).join(' ')
  }

  return (
    <div className="app">
      <header className="header">
        <h1>BioKey Full-Stack Demo</h1>
        <p className="server-status">{serverStatus}</p>
      </header>

      <div className="card">
        <div className="status-section">
          <div className="status-badge">
            <span className={`badge ${isEnrolled ? 'enrolled' : ''}`}>
              {isEnrolled ? '✓ Enrolled' : 'Not Enrolled'}
            </span>
            {status !== 'idle' && (
              <span className="status-text">{status}</span>
            )}
          </div>

          {isEnrolled && identity && (
            <div className="identity-card">
              <div className="identity-field">
                <label>Identity Key</label>
                <code>{formatKey(identity.publicKey)}</code>
              </div>
              <div className="identity-metadata">
                <span>Method: <strong>{identity.method}</strong></span>
                <span>Device: <strong>{identity.deviceId}</strong></span>
              </div>
            </div>
          )}

          {lastAuth && (
            <div className="auth-success">
              ✓ Authentication successful at {lastAuth.time}
              <br />
              <small>Method: {lastAuth.method}</small>
            </div>
          )}
        </div>

        {error && (
          <div className="error-box">
            <strong>Error:</strong> {error}
          </div>
        )}

        {!isEnrolled && (
          <div className="form-group">
            <label htmlFor="userId">User ID / Email</label>
            <input
              id="userId"
              type="email"
              value={userId}
              onChange={(e) => setUserId(e.target.value)}
              placeholder="[email protected]"
            />
          </div>
        )}

        <div className="actions">
          {!isEnrolled ? (
            <button 
              onClick={handleEnroll} 
              disabled={isLoading || !userId}
              className="btn btn-primary"
            >
              {isLoading ? '⏳ Waiting for biometric...' : '🔐 Enroll Biometric'}
            </button>
          ) : (
            <>
              <button 
                onClick={handleAuth} 
                disabled={isLoading}
                className="btn btn-success"
              >
                {isLoading ? '⏳ Authenticating...' : '✓ Authenticate'}
              </button>
              <button 
                onClick={() => {
                  reset()
                  setLastAuth(null)
                }}
                className="btn btn-ghost"
              >
                Reset
              </button>
            </>
          )}
        </div>

        <div className="info-box">
          <p><strong>How it works:</strong></p>
          <ol>
            <li>Client derives identity key from biometric</li>
            <li>Server stores public key for verification</li>
            <li>Server generates challenge for authentication</li>
            <li>Client proves identity without sending biometric data</li>
          </ol>
        </div>
      </div>
    </div>
  )
}

export default App

Running the Full-Stack App

1

Start the Server

cd server
npm install
npm run dev
Server will run on http://localhost:3000
2

Start the Client

cd client
npm install
npm run dev
Client will run on http://localhost:5173
3

Test the Flow

  1. Open http://localhost:5173 in your browser
  2. Click “Enroll Biometric” and use your fingerprint
  3. Click “Authenticate” to verify your identity
  4. Check the server logs to see the verification flow

API Endpoints

POST /enroll

Enroll a new user identity. Request:
{
  "userId": "[email protected]",
  "publicKey": "a1b2c3d4...",
  "deviceId": "device123",
  "method": "prf"
}
Response:
{
  "ok": true,
  "userId": "[email protected]",
  "publicKey": "a1b2c3d4...",
  "method": "prf"
}

GET /challenge

Generate a random authentication challenge. Response:
{
  "challenge": "a1b2c3d4e5f6..."
}

POST /verify

Verify user authentication. Request:
{
  "userId": "[email protected]",
  "challenge": "a1b2c3d4e5f6..."
}
Response:
{
  "verified": true,
  "publicKey": "a1b2c3d4...",
  "userId": "[email protected]",
  "method": "prf"
}

Security Considerations

Challenges expire after 5 minutes to prevent replay attacks. The server automatically cleans up old challenges.
WebAuthn requires HTTPS in production. Use a reverse proxy like Caddy or nginx with SSL certificates.
Add rate limiting to prevent brute-force attacks:
import { rateLimiter } from 'hono-rate-limiter'

app.use('/verify', rateLimiter({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5 // 5 attempts
}))
  • Use parameterized queries (already implemented)
  • Hash sensitive data before storage
  • Implement proper access controls
  • Regular backups

Production Deployment

Deploy Server

services:
  biokey-server:
    build:
      dockerfile: Dockerfile
    env:
      NODE_ENV: production
    healthcheck:
      endpoint: /
      interval: 30

Deploy Client

Vercel
cd client
npm run build
vercel --prod
Update SERVER_URL: Change the SERVER_URL constant in App.jsx to your production server URL.

Next Steps

Server API Reference

Complete API documentation for biokey-server

Security Guide

Best practices for production deployments

Build docs developers (and LLMs) love