umma.dev

Biometric Authentication in Web Apps

What is Web Biometric Authentication?

Web biometrics transform how users prove their identity online. Instead of typing passwords, users can use physical characteristics like fingerprints or facial recognition. When you unlock your phone with your fingerprint, you’re using biometric authentication - the web version works similarly.

Available Biometric Methods

Modern browsers support several biometric authentication methods:

  • Fingerprint scanning (most common on laptops and phones)
  • Face recognition (prevalent on newer devices)
  • Security keys (physical USB devices that complement biometric auth)
  • Voice recognition (emerging technology)

Browser Support

The Web Authentication API (WebAuthn) powers biometric authentication across modern browsers:

Chrome  67+   βœ“ Full Support
Firefox 60+   βœ“ Full Support
Safari  13+   βœ“ Full Support
Edge    18+   βœ“ Full Support
iOS     14+   βœ“ Full Support
Android 7+    βœ“ Full Support (with hardware)

Each browser handles biometrics slightly differently:

  • Safari requires HTTPS
  • Mobile browsers use platform-specific authenticators
  • Desktop browsers support both built-in and external authenticators

Technical Implementation

Registration Flow

  1. Server generates a cryptographic challenge
  2. Browser activates the device’s biometric sensor
  3. User provides biometric data
  4. Device creates a public-private key pair
  5. Public key goes to the server; private key stays on device

Authentication Flow

  1. Server issues a challenge
  2. Browser requests biometric verification
  3. Device signs the challenge using the stored private key
  4. Server validates the signature with the public key

React and Supabase Implementation

// BiometricAuth.jsx
import React, { useState } from "react";
import {
  startRegistration,
  startAuthentication,
} from "@simplewebauthn/browser";
import { supabase } from "../lib/supabase";

export function BiometricAuth() {
  const [status, setStatus] = useState("idle");

  const registerBiometric = async () => {
    try {
      setStatus("registering");

      // Get registration options from server
      const { data } = await supabase.functions.invoke(
        "get-registration-options"
      );

      // Start browser registration process
      const response = await startRegistration(data.options);

      // Verify with server
      await supabase.functions.invoke("verify-registration", {
        body: { response },
      });

      setStatus("registered");
    } catch (error) {
      setStatus("error");
      console.error(error);
    }
  };

  return (
    <div>
      <button onClick={registerBiometric} disabled={status === "registering"}>
        Register Fingerprint
      </button>

      {status === "registered" && (
        <div>Fingerprint registered successfully</div>
      )}
    </div>
  );
}

Database Schema (Supabase)

create table public.credentials (
  id uuid primary key default uuid_generate_v4(),
  user_id uuid references auth.users not null,
  public_key bytea not null,
  counter bigint default 0,
  created_at timestamptz default now()
);

-- Enable row-level security
alter table public.credentials enable row level security;

-- Allow users to manage their own credentials
create policy "Users manage own credentials"
  on public.credentials
  for all
  using (auth.uid() = user_id);

Server-Side Verification (Supabase Edge Function)

// verify-registration.ts
import { verifyRegistrationResponse } from "@simplewebauthn/server";

export async function handler(req: Request) {
  const { response } = await req.json();

  const verification = await verifyRegistrationResponse({
    response,
    expectedOrigin: "https://your-app.com",
    expectedRPID: "your-app.com",
  });

  if (verification.verified) {
    await supabase.from("credentials").insert({
      user_id: req.user.id,
      public_key: verification.registrationInfo.credentialPublicKey,
    });
  }

  return new Response(JSON.stringify({ verified: verification.verified }));
}

Security Context

HTTPS represents a fundamental requirement for biometric authentication. Without it, browsers block WebAuthn operations entirely. The security model depends on challenge-response verification - each authentication attempt generates a unique challenge that expires after use. This prevents replay attacks where malicious actors could capture and reuse authentication data.

Public key storage influences the overall security posture. Storing public keys in databases requires careful consideration of the storage format - while some databases handle byte arrays natively, others need base64 encoding. The storage must maintain the exact byte representation of the key without any corruption that could occur during type conversions.

User Experience

Biometric authentication flows start with sensor availability checks. Different devices expose different capabilities - laptops might offer fingerprint readers, while phones could provide facial recognition. The application needs to detect these capabilities and present appropriate options to users.

const checkBiometricCapabilities = async () => {
  if (!window.PublicKeyCredential) {
    return { available: false, reason: "API_NOT_SUPPORTED" };
  }

  const available =
    await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
  if (!available) {
    return { available: false, reason: "NO_AUTHENTICATOR" };
  }

  // Check for specific capabilities
  const capabilities = await navigator.credentials
    .get({
      publicKey: {
        challenge: new Uint8Array(32),
        userVerification: "preferred",
      },
    })
    .catch(() => null);

  return {
    available: true,
    capabilities: {
      fingerprint: capabilities?.fingerprint ?? false,
      faceID: capabilities?.faceID ?? false,
    },
  };
};

Status feedback during biometric operations proves crucial. Users need clear indicators of:

  • When to provide biometric input
  • Whether the operation succeeded or failed
  • What to do if authentication fails
function BiometricStatus({ state, onRetry }) {
  const states = {
    waiting: {
      icon: <ScanningIcon />,
      message: "Waiting for biometric verification...",
    },
    success: {
      icon: <CheckIcon />,
      message: "Verification complete",
    },
    error: {
      icon: <ErrorIcon />,
      message: "Verification failed",
      action: <button onClick={onRetry}>Try Again</button>,
    },
  };

  const current = states[state];
  return (
    <div className="flex items-center gap-2">
      {current.icon}
      <span>{current.message}</span>
      {current.action}
    </div>
  );
}

Implementation Details

Browser compatibility extends beyond simple version checks. Different browsers implement the WebAuthn spec with subtle variations:

const getBrowserCapabilities = () => {
  const ua = navigator.userAgent;
  const isSafari = /^((?!chrome|android).)*safari/i.test(ua);
  const isChrome = /chrome|chromium/i.test(ua);

  return {
    requiresUserGesture: isSafari,
    supportsResidentKeys: isChrome,
    maxTimeout: isSafari ? 300000 : 600000, // Safari has shorter timeout
  };
};

Timeout handling affects reliability. Biometric operations can fail if they take too long, but timeouts that are too short might interrupt slow sensors or users who need more time:

const withTimeout = (promise, ms) => {
  let timeoutId;

  const timeout = new Promise((_, reject) => {
    timeoutId = setTimeout(() => {
      reject(new Error("Biometric verification timed out"));
    }, ms);
  });

  return Promise.race([promise, timeout]).finally(() => {
    clearTimeout(timeoutId);
  });
};

// Usage
const authenticateWithTimeout = () => {
  const capabilities = getBrowserCapabilities();
  return withTimeout(startAuthentication(options), capabilities.maxTimeout);
};

Concurrent request management prevents race conditions where multiple authentication attempts overlap. A simple semaphore pattern helps:

class BiometricLock {
  constructor() {
    this.locked = false;
  }

  async acquire() {
    if (this.locked) {
      throw new Error("Biometric operation in progress");
    }
    this.locked = true;
  }

  release() {
    this.locked = false;
  }
}

const biometricLock = new BiometricLock();

async function authenticateUser() {
  try {
    await biometricLock.acquire();
    const result = await startAuthentication(options);
    return result;
  } finally {
    biometricLock.release();
  }
}

Biometric authentication brings native-level security to web applications. The WebAuthn API provides a robust foundation for implementing these features, while libraries like SimpleWebAuthn simplify the integration process. As browser support continues to evolve, biometric authentication will likely become increasingly prevalent in web applications.

The WebAuthn specification and browser documentation provide extensive technical details about implementation possibilities and security considerations.