Biometric Authentication: How It Works and When It Fails You
- Biometrics don't authenticate a person β they authenticate that a specific physical trait was present on a specific enrolled device. That distinction matters enormously for threat modelling: remote credential theft becomes much harder, but physical device compromise becomes the new attack surface.
- The threshold decision (FAR vs FRR) is a business decision disguised as a technical one β at 4M users, a 0.1% FRR increase translates to 4,000 failed logins per day and potentially $40K/day in call centre costs. Run the numbers before product tells you to 'just make it stricter'.
- Use FIDO2/WebAuthn with on-device matching for any new system β you get phishing resistance, zero template storage liability, and GDPR/BIPA compliance essentially for free. Server-side biometric matching is only worth the operational and legal complexity if you have a specific cross-device use case that genuinely cannot be served any other way.
A major Southeast Asian bank's mobile app failed a PCI-DSS audit in 2022 because their biometric layer was storing raw fingerprint images in a local SQLite database on the device β unencrypted, backed up to iCloud by default. Every successful login was also silently backing up the user's biometric template to a consumer cloud account the security team had zero control over. That's not a hypothetical. That's what happens when teams treat biometrics as a UX feature instead of a cryptographic identity primitive.
The problem biometric authentication solves is real and unsolved by passwords alone: humans are catastrophically bad at secret management. They reuse passwords, write them on sticky notes, and surrender them to the first convincing phishing email they get. Hardware tokens help, but they get lost. Biometrics are different β they bind authentication to something you physically are, which dramatically raises the cost of remote credential theft. That matters right now because credential stuffing attacks have become industrialised. Buying 50 million username/password pairs costs less than a decent dinner.
By the end of this article you'll be able to design a biometric authentication flow that doesn't leak templates, explain the FAR/FRR trade-off to a product manager without putting them to sleep, identify the four most common ways biometric systems get defeated in production, and make an informed architecture call between on-device matching and server-side matching. You'll also know exactly when to tell your CTO that biometrics alone aren't enough.
The Four Biometric Types and Their Real-World Attack Surfaces
Every biometric modality makes a different bet on the uniqueness and permanence of a physical trait. Understanding that bet is the only way to reason about the attack surface you're accepting.
Fingerprint recognition is the most widely deployed modality because the sensors are cheap, fast, and well-understood. The matching algorithm extracts minutiae points β ridge endings and bifurcations β and compares them against an enrolled template. The core vulnerability isn't the algorithm; it's liveness detection. A 2019 study from Cisco Talos showed that high-resolution fingerprint photos lifted from a wine glass could defeat capacitive sensors on most mid-range Android devices using a $500 mould-and-cast workflow. If your threat model includes targeted physical attacks on high-value accounts, fingerprint alone is a bad bet.
Facial recognition splits into two very different things people often conflate: 2D face matching (a photo comparison, basically) and 3D structured light or time-of-flight depth mapping like Apple Face ID. The 2D variant is trivially defeated by a photograph. Don't ship it for anything that matters. The 3D variant is genuinely hard to spoof β Face ID's published false acceptance rate is 1 in 1,000,000 β but it requires expensive dedicated hardware and fails non-trivially in bright outdoor light and at extreme angles.
Voice recognition and iris scanning round out the common deployment options. Iris is extremely accurate (false acceptance rates around 1 in 1.2 million in controlled conditions) but requires dedicated near-infrared hardware and degrades badly with contact lenses and certain eye conditions. Voice recognition is the weakest of the four in 2024 β modern voice synthesis models can clone a voice from 30 seconds of audio. If you're designing a phone-based authentication flow, voice biometrics should be treated as a convenience factor only, never as a primary security control.
// io.thecodeforge β System Design tutorial // Biometric Modality Selection β Architecture Decision Record // Scenario: Multi-channel fintech app selecting auth modality per channel // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // SYSTEM CONTEXT // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // Channels: Mobile (iOS + Android), Web portal, IVR phone banking // Users: ~4M retail banking customers // Threat model: Remote credential stuffing, stolen device, SIM swap // (NOT nation-state targeted physical attacks) // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // MODALITY DECISION TREE β per channel // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ CHANNEL: Mobile (iOS 14+, Android 9+) βββ Primary: On-device fingerprint OR Face (delegated to OS biometric API) β WHY: We never see the raw biometric β OS handles enrollment β and matching inside the Secure Enclave / TEE. We only get β a signed cryptographic assertion: "user authenticated". β This eliminates template storage liability entirely. β βββ Hardware: Apple Secure Enclave (A7+), Android StrongBox / TEE β REQUIREMENT: Confirm hardware-backed key storage at β enrollment time. Reject software-only fallbacks. β Android API: KeyInfo.isInsideSecureHardware() == true β iOS API: SecAccessControlCreateWithFlags + .biometryAny β βββ Fallback: Device PIN (NOT SMS OTP β defeats SIM swap protection) β βββ REJECT: Raw image/template capture from custom SDK sensors. Legal exposure: BIPA (Illinois), GDPR Art.9 (special category). Operational exposure: You now own a biometric data breach. CHANNEL: Web Portal (desktop browser) βββ Primary: FIDO2 / WebAuthn with platform authenticator β WHY: Browser biometric APIs (navigator.credentials) delegate β to the OS β same Secure Enclave path as mobile. β Template never leaves the device. Ever. β βββ Fallback: FIDO2 hardware key (YubiKey) β for power users / ops staff β βββ REJECT: Browser-based face capture via WebRTC + cloud matching. Latency is 800msβ2s round-trip. Liveness detection requires server-side ML infra you have to maintain and retrain. Not worth it when WebAuthn gives you better security for free. CHANNEL: IVR Phone Banking βββ Primary: NONE β do not use voice biometrics as a security control. β WHY: Voice synthesis (ElevenLabs, RVC, VALL-E) clones voice β from publicly available audio. LinkedIn videos. Earnings calls. β Customer service recordings. The attack cost is ~$0. β βββ Alternative: Step-up to mobile push notification with biometric β confirmation on the registered device. Forces the attacker β to control both the phone call AND the enrolled device. β βββ If voice biometrics are mandated by business: treat as a single factor in a 2FA flow β never as the sole gate. Document the risk acceptance. // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // FAR / FRR OPERATING POINTS β what the product team needs to understand // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ FAR = False Acceptance Rate β impostors incorrectly granted access FRR = False Rejection Rate β legitimate users incorrectly denied // These are inversely coupled. Lowering FAR raises FRR and vice versa. // The operating point is a BUSINESS DECISION, not a technical one. Modality Typical FAR Typical FRR Notes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ Fingerprint 0.001% β 0.1% 0.1% β 1% Degrades: wet/dry fingers Face 3D (Face ID) 0.0001% ~1β2% Degrades: masks, sunglasses Face 2D 1% β 5% 0.5% NEVER use for finance Iris 0.00008% 0.3% Hardware cost is prohibitive Voice 1% β 10% 1% β 5% Synthetic audio attack: ~100% FAR // At 4M users, even a 0.1% FRR means 4,000 locked-out customers per day. // Your call centre cost per failed auth interaction: ~$8β12. // That's $32,000β$48,000/day in hidden costs from a threshold decision. // Run this number in front of your product manager before finalising thresholds.
Mobile channel: Fingerprint/Face via OS API β hardware-backed key assertion
Web channel: FIDO2/WebAuthn platform authenticator
IVR channel: Voice biometrics REJECTED β step-up to mobile push 2FA
FAR/FRR operating point requires business sign-off.
At 4M users + 0.1% FRR β 4,000 failed auths/day β ~$40,000/day call centre exposure.
Recommend starting at vendor default threshold and A/B testing tighter values.
Template storage liability: ZERO (all matching delegated to device OS).
GDPR Art.9 / BIPA exposure: ZERO (no biometric data leaves the device).
How Biometric Matching Actually Works: Templates, Thresholds, and the Liveness Problem
Most engineers treat the biometric sensor as a black box that returns true or false. That mental model will burn you in production. The actual pipeline has five stages, each with its own failure mode.
Capture β Feature Extraction β Template Generation β Matching β Decision. The sensor captures a raw signal β pixels, capacitance grid, acoustic waveform. The feature extractor converts that signal into a compact mathematical representation called a template β for fingerprints, that's typically a set of (x, y, angle) tuples for minutiae points, stored as a vector of 400β1,000 bytes. The matcher computes a similarity score between the live template and the enrolled template. The decision module applies a threshold to that score.
The threshold is where everything gets political. Security teams want a low FAR β they want to minimise impostors getting through. Product teams want a low FRR β they want to minimise frustrated legitimate users calling support. These goals are mathematically opposed. You can't improve both simultaneously with the same algorithm. The Equal Error Rate (EER) is the operating point where FAR equals FRR, and it's used as a single-number benchmark, but you almost never want to operate at EER in production. You tune based on the cost of each error type in your specific context.
Liveness detection is the layer that gets skipped in proof-of-concepts and costs you in production. Without it, a static artefact β a photo, a mould, a replay attack β gets the same similarity score as a live person. PAD (Presentation Attack Detection) is a separate subsystem, and its quality varies enormously across vendors. When you're evaluating a biometric SDK, the ISO/IEC 30107-3 PAD compliance level is the number you ask for, not the marketing FAR figure.
// io.thecodeforge β System Design tutorial // Biometric Matching Pipeline β Sequence + Data Flow // Scenario: Mobile banking app, on-device matching via Android BiometricPrompt // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // SEQUENCE: Enrollment (one-time, at account setup) // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ User β App: "Enable biometric login" App β OS BiometricManager: CHECK canAuthenticate(BIOMETRIC_STRONG) // BIOMETRIC_STRONG requires hardware-backed credential // BIOMETRIC_WEAK allows face unlock via camera (lower assurance) // NEVER use DEVICE_CREDENTIAL alone for financial flows IF result == BIOMETRIC_ERROR_NO_HARDWARE: // Device has no biometric hardware. Offer PIN fallback. // Log telemetry β useful for device support decisions. ABORT enrollment, present PIN setup IF result == BIOMETRIC_ERROR_NONE_ENROLLED: // Hardware exists but user hasn't enrolled a fingerprint/face. // Direct to system Settings β you CANNOT enroll on their behalf. LAUNCH Intent(Settings.ACTION_BIOMETRIC_ENROLL) App β Android Keystore: // Generate an asymmetric key pair BOUND to biometric authentication. // This is the critical step. The private key is: // - Stored in hardware-backed Keystore (TEE or StrongBox) // - ONLY usable after successful biometric auth in the same session // - Never exportable β cannot be read out of the hardware, ever KeyPairGenerator.getInstance("EC", "AndroidKeyStore") KeyGenParameterSpec.Builder("biometric_auth_key", PURPOSE_SIGN) .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) .setUserAuthenticationRequired(true) // BOUND to biometric .setUserAuthenticationParameters( timeout = 0, // 0 = require fresh auth type = AUTH_BIOMETRIC_STRONG // NOT weak face unlock ) .setInvalidatedByBiometricEnrollment(true) // KEY IS DELETED if new // fingerprint is added. // Prevents adding attacker // fingerprint = instant access. App β Backend API: POST /biometric/enroll PAYLOAD: { userId, publicKey (PEM), deviceId, keyAttestation } // keyAttestation is a certificate chain from the hardware proving the key // genuinely lives in hardware. Verify this server-side β DO NOT skip it. // An unattested key could be a software key on a rooted device. Backend: VERIFY keyAttestation certificate chain against Google's root CA // Google publishes root certificates for hardware attestation. // If attestation fails: reject enrollment, flag account for review. STORE { userId, publicKey, deviceId, enrolledAt } // You are storing a PUBLIC KEY. Not a fingerprint. Not a template. // A breach of this table leaks nothing biometric. // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // SEQUENCE: Authentication (every login) // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ User β App: "Log in with fingerprint" App β Backend: GET /biometric/challenge RESPONSE: { challenge: "<32-byte cryptographically random nonce>" } // The challenge MUST be server-generated and single-use. // Client-generated challenges = replay attack surface. // Store challenge server-side with a 60-second TTL. App β OS BiometricPrompt: // Show system fingerprint dialog. // Pass a CryptoObject wrapping a Signature initialised with private key. BiometricPrompt.authenticate( CryptoObject(signature), cancellationSignal, executor, authCallback ) OS β Secure Hardware: BIOMETRIC MATCHING HAPPENS HERE β entirely inside TEE/Secure Enclave The app NEVER sees the fingerprint image or template. The hardware returns: SUCCESS or FAILURE On SUCCESS: unlocks the private key for use within this session OS β App (on success): // The Signature object is now usable β private key is unlocked. signature.update(challengeBytes) // Sign the server's nonce signedChallenge = signature.sign() // Produces EC signature App β Backend: POST /biometric/verify PAYLOAD: { userId, deviceId, signedChallenge, challengeId } Backend: RETRIEVE challenge by challengeId β verify it's < 60 seconds old MARK challenge as consumed β prevents replay within TTL window RETRIEVE publicKey for userId + deviceId VERIFY ECDSA signature over challenge bytes using stored publicKey // Standard ECDSA verify. No biometric data ever reaches the server. // If signature is valid: the user's finger was on the enrolled device. // That's the cryptographic guarantee. Nothing more, nothing less. IF valid: ISSUE short-lived JWT (15min) + refresh token (rotated on each use) LOG auth event: { userId, deviceId, timestamp, ipAddress, geoHash } // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // WHAT HAPPENS WHEN SOMEONE ADDS A NEW FINGERPRINT TO THE DEVICE // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // setInvalidatedByBiometricEnrollment(true) means: // β Android deletes biometric_auth_key from Keystore automatically // β Next login attempt: KeyPermanentlyInvalidatedException thrown // β App detects this, prompts: "Your biometric login was reset. // Please log in with your password to re-enrol." // β Requires password re-authentication before new biometric enrolment // // WHY THIS MATTERS: Without this, an attacker who has brief physical // access to an unlocked phone can add THEIR fingerprint to the device // and immediately gain access to the banking app with their own finger. // This flag closes that attack vector.
canAuthenticate(BIOMETRIC_STRONG) β SUCCESS
KeyPairGenerator β EC key generated in StrongBox hardware
keyAttestation verified against Google hardware attestation root CA
Backend stores: { userId, publicKeyPEM, deviceId, enrolledAt }
Biometric data stored: NONE (all in device hardware)
AUTHENTICATION FLOW:
Challenge issued: 32-byte nonce, TTL=60s, single-use
BiometricPrompt shown β fingerprint matched inside TEE
Private key unlocked β ECDSA signature over challenge computed
Backend: challenge consumed, signature verified β
JWT issued: exp=15min | refresh token rotated
SECURITY PROPERTIES:
Template breach risk: NONE (never leaves device hardware)
Replay attack surface: NONE (single-use challenge, 60s TTL)
New-fingerprint attack: BLOCKED (key invalidated on enrol change)
Rooted device / soft key: BLOCKED (hardware attestation required)
Where Biometric Systems Break Down in Production
The failure modes that actually show up in incident post-mortems aren't the ones in the threat model documents. They're operational, edge-case, and demographic.
Template aging is the slow-burn failure nobody plans for. Fingerprints change. Aging, manual labour, chemotherapy, eczema, and significant weight changes all degrade match quality over time. I've seen a healthcare company's nurse workforce hit a 12% FRR spike β meaning roughly 1 in 8 nurses couldn't authenticate at the medication dispensing terminal β because their templates were enrolled 18 months prior and nobody had built a re-enrollment workflow. The fix isn't a better algorithm. It's building periodic re-enrollment prompts into your UX from day one.
Biometric fallback paths are where most security properties go to die. Users who can't authenticate biometrically fall back to PIN, password, or SMS OTP. If that fallback is weaker than the primary path, attackers just target the fallback. I've seen apps with Face ID that fell back to a 4-digit PIN β making the entire biometric layer security theatre. Your fallback must be designed as a primary security control, not an afterthought.
Privacy regulations create operational complexity that engineers routinely underestimate. BIPA in Illinois requires written consent and a published retention policy before collecting biometric identifiers. GDPR Article 9 classifies biometric data as special category data requiring explicit legal basis. If you're building server-side biometric matching β even as a backend service your mobile app calls β you need legal sign-off in every jurisdiction where your users live before you ship. The on-device matching architecture I showed in the previous section isn't just a security choice; it's also how you avoid being BIPA's next headline.
// io.thecodeforge β System Design tutorial // Biometric Failure Mode Catalogue β Production Incident Patterns // Scenario: High-availability fintech app, 4M users, 99.9% auth SLA // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // FAILURE MODE 1: KeyPermanentlyInvalidatedException // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ SYMPTOM: User taps "Login with fingerprint" App throws: KeyPermanentlyInvalidatedException User sees: generic error screen or app crash (if unhandled) CAUSE: A) User added/removed a fingerprint on their device B) User changed/removed screen lock (depending on key config) C) Device was enrolled with setInvalidatedByBiometricEnrollment(true) β which is correct behaviour, not a bug HANDLING: catch (KeyPermanentlyInvalidatedException e) { // Do NOT show a generic error. The user isn't broken. // Their security configuration changed. That's expected. // 1. Delete the invalidated key from local Keystore keyStore.deleteEntry("biometric_auth_key") // 2. Revoke the associated public key on the server // POST /biometric/revoke { userId, deviceId } // This prevents the old public key being used in a confused deputy attack apiClient.revokeBiometricKey(userId, deviceId) // 3. Present clear UX: "Your biometric login needs to be reset" // Require password authentication before re-enrollment // This is intentional friction β it's the security checkpoint navigator.navigate(Route.BiometricReenrollment) } // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // FAILURE MODE 2: Biometric lockout after failed attempts // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ SYMPTOM: After 5 failed fingerprint attempts, Android locks biometric auth. App receives: BiometricPrompt.ERROR_LOCKOUT (error code 7) After extended failures: ERROR_LOCKOUT_PERMANENT (error code 9) User is stuck β biometric is disabled until device PIN is entered. CAUSE: OS-enforced anti-brute-force protection. This is correct. But apps that don't handle it gracefully strand the user. HANDLING: onAuthenticationError(errorCode, errString) { when (errorCode) { BIOMETRIC_ERROR_LOCKOUT -> { // Temporary lockout (30 seconds typically) // Guide user to verify with device PIN/password // This unlocks biometric after successful PIN entry showMessage("Too many attempts. Verify with your PIN to continue.") // Offer explicit "Use PIN instead" button β don't just show error } BIOMETRIC_ERROR_LOCKOUT_PERMANENT -> { // Requires device PIN. Cannot be unlocked programmatically. // Offer password-based app login as parallel path showMessage("Biometric locked. Log in with your password.") // Log this event β spike in LOCKOUT_PERMANENT is an // indicator of a targeted brute-force campaign on physical devices } BIOMETRIC_ERROR_NO_BIOMETRICS -> { // User removed all enrolled biometrics since last session // Treat same as KeyPermanentlyInvalidatedException path } } } // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // FAILURE MODE 3: Template aging / demographic degradation // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ SYMPTOM: FRR rises gradually over months. Support tickets: "My fingerprint doesn't work anymore" β user hasn't changed anything. Affects specific cohorts disproportionately: manual workers, elderly users, users with skin conditions. CAUSE: Enrolled template no longer matches aged/changed biometric closely enough to exceed the matching threshold. This is a physics problem, not a bug. MITIGATION: // Monitor per-cohort FRR in your auth analytics pipeline // Alert threshold: FRR rising above 2% in any user segment // Build proactive re-enrollment: IF (daysSinceEnrollment > 365) AND (recentAuthSuccessRate < 0.95): PROMPT user on next successful login: "Update your biometric for better reliability" (not security framing) // Users accept re-enrollment when framed as convenience, not security // On-device matching note: you can't query match scores directly from // Android BiometricPrompt β it's binary success/failure. // Infer quality from FRR trends in your server-side auth event log. // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // FAILURE MODE 4: Weak fallback path undermining biometric security // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ SYMPTOM (SECURITY): Biometric auth is bypassed entirely by attackers who target the fallback. App has Face ID β fallback is SMS OTP β SIM swap = full account takeover. The biometric layer added zero net security. ANTI-PATTERN: Biometric β SMS OTP fallback (SIM swap defeats it) Biometric β 4-digit PIN fallback (brute-forceable offline if device stolen) Biometric β Security question (social engineering / data breach defeats it) CORRECT PATTERN: Biometric β Device PIN/Password fallback (OS-enforced, hardware-encrypted) WHY: If an attacker has the device and the PIN, they already have physical possession. The threat model at that point is device loss, not remote compromise. Device-level encryption handles that β not your app. For step-up actions (transfers > $5,000, change of registered device): NEVER accept biometric OR fallback alone. REQUIRE: biometric + separate OTP to registered email or authenticator app. Make fallback paths trigger step-up re-verification, not bypass it.
KeyPermanentlyInvalidatedException β HANDLED: key revoked, re-enroll flow
BiometricPrompt.ERROR_LOCKOUT β HANDLED: PIN fallback presented
BiometricPrompt.ERROR_LOCKOUT_PERM β HANDLED: password login offered, event logged
Template aging / FRR drift β MITIGATED: re-enrollment prompt at 365d
Weak fallback (SMS OTP) β BLOCKED: device PIN only as fallback
Step-up bypass via fallback β BLOCKED: high-value actions require 2FA regardless
AUTH ANALYTICS ALERT RULES:
FRR > 2% in any cohort β PagerDuty: P2 (likely template aging)
ERROR_LOCKOUT_PERMANENT spike β PagerDuty: P1 (possible brute-force campaign)
Attestation failures > 0.1% β PagerDuty: P1 (rooted device activity)
FIDO2 and WebAuthn: Why the Architecture Matters More Than the Biometric
The biometric modality is almost irrelevant if your architecture stores templates on a server. The FIDO2 protocol solves the hardest problem in biometric system design: how do you get cryptographic proof that a biometric match happened, without the biometric data ever leaving the device?
The answer is the same public-key cryptography pattern I showed earlier, standardised and vendor-independent. WebAuthn (the browser API) and CTAP2 (the protocol between devices and authenticators) together give you a biometric authentication flow that's phishing-resistant, replay-resistant, and template-breach-resistant by design. Not as a feature. As a mathematical property.
Phishing resistance deserves particular emphasis because it's where FIDO2 separates from every other MFA scheme. The credential is scoped to the exact origin it was registered against. A user enrolled on bank.example.com cannot be tricked into authenticating to b4nk.example.com β the browser enforces origin binding at the protocol level. No other biometric implementation gives you this property without building it yourself.
The operational trade-off is account recovery. If a user loses their only enrolled device, they need a recovery path that doesn't undermine the security model. The standard answers are: multiple enrolled devices (encourage users to enrol on tablet + phone), recovery codes (TOTP-style, printed, stored physically), or a trusted identity verification step (ID document check, video call). None of these are frictionless. Choose based on your user population and regulatory requirements.
// io.thecodeforge β System Design tutorial // FIDO2 / WebAuthn Biometric Auth β Full System Architecture // Scenario: Web + mobile banking portal, replacing password + SMS 2FA // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // COMPONENTS // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ [Client: Browser / Mobile App] β WebAuthn API / CTAP2 [Authenticator: Platform (Secure Enclave) or Roaming (YubiKey)] β HTTPS [Relying Party Server: Your backend] β Internal [Credential Store: Database β public keys ONLY] // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // REGISTRATION (FIDO2 Enrollment) // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ STEP 1 β Server generates registration challenge: POST /webauthn/register/begin INPUT: { userId, username } Server creates PublicKeyCredentialCreationOptions: { challenge: crypto.randomBytes(32), // Single-use, 5-min TTL rp: { id: "bank.example.com", // Relying Party ID β MUST match origin name: "Example Bank" }, user: { id: userId (Buffer), name: "user@example.com", displayName: "Jane Smith" }, pubKeyCredParams: [ { type: "public-key", alg: -7 }, // ES256 (ECDSA P-256) β prefer this { type: "public-key", alg: -257 } // RS256 β fallback for older YubiKeys ], authenticatorSelection: { authenticatorAttachment: "platform", // Use built-in biometric (Touch ID etc.) // "cross-platform" = YubiKey; omit to allow both userVerification: "required", // MUST verify user (biometric or PIN) // "preferred" is NOT sufficient for financial auth β attacker device // without biometric enrolled would skip verification entirely residentKey: "required" // Passkey: credential stored on device }, attestation: "direct", // "direct": get the authenticator's attestation statement // Lets you verify the credential came from genuine Apple/Google hardware // vs a software authenticator on a rooted device // Note: Apple anonymises attestation by default ("apple" format) // β you get authenticity proof without tracking individual devices timeout: 300000 // 5 minutes for registration flow } STEP 2 β Browser calls navigator.credentials.create(): OS shows biometric prompt (Touch ID, Face ID, Windows Hello, etc.) User verifies with biometric or device PIN Platform Authenticator generates: - New EC key pair (private key stays in Secure Enclave FOREVER) - credentialId: random identifier for this credential - attestationObject: signed proof of hardware origin - clientDataJSON: includes challenge + origin (phishing protection) STEP 3 β Client sends registration response to server: POST /webauthn/register/complete PAYLOAD: { id: credentialId (base64url), response: { clientDataJSON: "<base64url>", attestationObject: "<base64url>" } } STEP 4 β Server verifies registration: // USE A WELL-MAINTAINED FIDO2 LIBRARY. Do NOT parse this manually. // Java: webauthn4j | Node: @simplewebauthn/server | Python: py_webauthn a) Parse clientDataJSON: - Verify type == "webauthn.create" - Verify challenge matches stored challenge (and consume it) - Verify origin == "https://bank.example.com" β phishing protection - Verify rpIdHash == SHA256("bank.example.com") b) Parse attestationObject: - Verify authenticator data flags: bit 0 (UP): User Present β MUST be set bit 2 (UV): User Verified β MUST be set (we required userVerification) bit 6 (AT): Attested Credential Data β MUST be set for registration - Extract credentialPublicKey and credentialId - Verify attestation signature against attestation certificate c) Store in database: { userId, credentialId, publicKey(COSE format), signCount: 0, createdAt, deviceName: user-supplied, lastUsed: null } // signCount is a monotonic counter β the authenticator increments it // each authentication. If server receives a count <= stored count: // the credential was CLONED. Flag immediately. // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // AUTHENTICATION (FIDO2 Login) // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ STEP 1 β Server issues authentication challenge: GET /webauthn/auth/begin?userId=<id> RESPONSE: PublicKeyCredentialRequestOptions: { challenge: crypto.randomBytes(32), rpId: "bank.example.com", allowCredentials: [{ type: "public-key", id: credentialId }], userVerification: "required", timeout: 120000 } STEP 2 β Browser calls navigator.credentials.get(): OS shows biometric prompt User verifies Platform Authenticator: - Unlocks private key - Increments signCount - Signs: SHA256(clientDataJSON) + authenticatorData STEP 3 β Verify on server: a) Verify clientDataJSON (same checks as registration) b) Verify rpIdHash + flags (UP + UV must be set) c) Verify signCount > storedSignCount IF signCount <= storedSignCount: // Authenticator cloning detected β this is serious. // Revoke credential. Force re-enrollment. // Alert security team. This is a P1 incident. REVOKE credential REQUIRE fresh authentication via alternative channel d) Verify ECDSA/EdDSA signature using stored public key e) Update stored signCount f) Issue session token // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // WHAT MAKES THIS PHISHING-RESISTANT // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // The credential is bound to rpId = "bank.example.com" at creation time. // When an attacker proxies the user to "b4nk.example.com": // - Browser computes rpIdHash of "b4nk.example.com" // - No matching credential exists (enrolled for "bank.example.com") // - navigator.credentials.get() returns empty β auth fails // - Attacker cannot forward to real site β they don't have the signature // (the private key never left the user's device) // AitM proxy attacks that work against TOTP / SMS OTP: BLOCKED at protocol level. // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // ACCOUNT RECOVERY STRATEGY β don't skip this // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ OPTION A: Multi-device passkeys (iCloud Keychain / Google Password Manager) PRO: Survives device loss β credential synced across user's Apple/Google account CON: Biometric is now also protected by iCloud/Google account security If their Google account is compromised, so is the passkey SUITABLE FOR: Consumer apps, lower-risk transactions OPTION B: Recovery codes (printed, one-time use) PRO: No dependency on third-party account CON: Users lose them, store them insecurely, screenshot them SUITABLE FOR: Power users, developer tools, internal tooling OPTION C: Identity re-verification gate PRO: Strongest β requires proof of identity to re-enrol CON: Highest friction. 48-72 hour manual review process. SUITABLE FOR: High-value financial accounts, regulated identities DO NOT: Allow password reset to bypass FIDO2 credential management. If a password reset can add a new device credential without biometric, your entire FIDO2 security model collapses to password security.
Challenge issued: 32-byte nonce, TTL=300s, single-use
Platform authenticator: Touch ID / Face ID (Secure Enclave)
userVerification: required β β UV flag verified server-side
Attestation verified: Apple attestation certificate chain β
signCount stored: 0
Public key stored: ES256 COSE key
Biometric data stored server-side: NONE
FIDO2 AUTHENTICATION:
Challenge issued: 32-byte nonce, TTL=120s, single-use
User verified biometric on device β
signCount received: 1 > stored: 0 β (no cloning detected)
ECDSA signature verified against stored public key β
signCount updated to: 1
Session token issued
PHISHING TEST:
Attacker proxies user to b4nk.example.com
navigator.credentials.get() β no matching credential for b4nk.example.com
Authentication fails β AitM attack BLOCKED at protocol level β
| Attribute | On-Device Matching (FIDO2/TEE) | Server-Side Matching (Cloud Biometric API) |
|---|---|---|
| Template storage location | Device hardware (TEE/Secure Enclave) β never leaves | Vendor cloud or your own database β you own the breach risk |
| GDPR/BIPA compliance complexity | Low β no biometric data transmitted or stored server-side | High β explicit legal basis, DPA, retention policy required per jurisdiction |
| FAR/FRR tuning control | None β fixed by OS vendor (Apple/Google) | Full control β tune threshold per user cohort |
| Phishing resistance | Native (FIDO2 origin binding) | None β depends entirely on transport security |
| Offline capability | Full β matching is local | None β requires network round-trip |
| Infrastructure cost | Zero matching infra β outsourced to device OS | Significant β GPU inference, model serving, latency SLA |
| Liveness detection quality | High (Apple Face ID: structured light depth map) | Varies β depends on SDK vendor and model version |
| Cross-device portability | Passkeys: yes (iCloud/Google sync). Bound keys: no | Yes β template enrolled once, match from any device |
| Cloning / template theft impact | Private key in hardware β cryptographically non-exportable | Template leak enables synthetic recreation of biometric |
| Vendor lock-in | Low β FIDO2 is open standard | High β proprietary template formats, API contracts |
| Suitable for regulated finance | Yes β preferred architecture | Only if server-side matching is legally mandated (rare) |
π― Key Takeaways
- Biometrics don't authenticate a person β they authenticate that a specific physical trait was present on a specific enrolled device. That distinction matters enormously for threat modelling: remote credential theft becomes much harder, but physical device compromise becomes the new attack surface.
- The threshold decision (FAR vs FRR) is a business decision disguised as a technical one β at 4M users, a 0.1% FRR increase translates to 4,000 failed logins per day and potentially $40K/day in call centre costs. Run the numbers before product tells you to 'just make it stricter'.
- Use FIDO2/WebAuthn with on-device matching for any new system β you get phishing resistance, zero template storage liability, and GDPR/BIPA compliance essentially for free. Server-side biometric matching is only worth the operational and legal complexity if you have a specific cross-device use case that genuinely cannot be served any other way.
- The counterintuitive truth that separates engineers who've shipped biometrics from those who've only read about them: the biometric match itself is rarely the weakest link. The fallback path, the recovery flow, and the enrollment security gate are almost always where the real vulnerabilities live β and they get approximately 5% of the design attention.
β Common Mistakes to Avoid
- βMistake 1: Using BIOMETRIC_WEAK instead of BIOMETRIC_STRONG for financial auth on Android β symptom: users authenticate successfully with face unlock via the front camera (no depth sensor), which can be spoofed with a photo β fix: always pass BiometricManager.Authenticators.BIOMETRIC_STRONG to canAuthenticate() and KeyGenParameterSpec.setUserAuthenticationParameters(), and verify BiometricPrompt returns AUTHENTICATION_RESULT_TYPE_BIOMETRIC not AUTHENTICATION_RESULT_TYPE_DEVICE_CREDENTIAL
- βMistake 2: Storing the biometric enrollment public key without verifying the hardware attestation certificate chain β symptom: rooted devices can enroll with a software-generated key, bypassing all hardware security guarantees β fix: parse the attestationObject, walk the certificate chain to Google's root CA (or Apple's), and check KeyDescription extension OID 1.3.6.1.4.1.11129.2.1.17 confirms KEY_MASTER_SECURITY_LEVEL == TRUSTED_ENVIRONMENT or STRONG_BOX; reject registrations that fail this check
- βMistake 3: Setting userVerification: 'preferred' instead of 'required' in WebAuthn PublicKeyCredentialRequestOptions β symptom: on a device with no biometric enrolled, the browser silently skips user verification, sets the UV flag to 0, and the server accepts the assertion as a valid biometric auth β fix: always set userVerification: 'required' and verify the UV bit (bit 2) in authenticatorData.flags is set to 1 server-side; throw an AuthenticationException if UV is not set, regardless of what the client claims
Interview Questions on This Topic
- QWalk me through what happens cryptographically when a FIDO2 credential is used on two different devices simultaneously β specifically, how does the signCount mechanism detect cloning, and what are its limitations when the authenticator uses synced passkeys via iCloud Keychain?
- QYou're designing authentication for a mobile banking app with 5M users β when would you choose server-side biometric matching over the FIDO2/on-device approach, and what legal and operational obligations does that choice create?
- QA user reports they can no longer authenticate with their fingerprint after not using the app for 8 months β your server logs show KeyPermanentlyInvalidatedException. What are the three possible causes, which one is a security event requiring incident response, and how do you differentiate them in code?
Frequently Asked Questions
Can biometric data be stolen in a data breach?
With on-device matching via FIDO2, no β there's no biometric data on your servers to steal. The server only stores a public key. With server-side biometric matching, yes β your database contains biometric templates, and unlike passwords, you can't issue your users new fingerprints after a breach. This is the primary architectural reason to prefer on-device matching: a breach of your credential store leaks public keys that are mathematically useless to an attacker without the corresponding private key, which never leaves the user's device hardware.
What's the difference between biometric authentication and biometric identification?
Authentication is 1-to-1: 'Is this the same person who enrolled this account?' Identification is 1-to-many: 'Who among these 10 million enrolled people is this?' Almost everything in app security is authentication. Identification is used in law enforcement, border control, and mass surveillance β it's orders of magnitude harder, its FAR scales with database size, and it's almost never what you're building. When someone says 'facial recognition is inaccurate', they're usually citing identification accuracy at scale β not the FAR of a 1-to-1 match on a modern device.
How do I handle biometric authentication on Android devices that don't have a fingerprint sensor?
Call BiometricManager.canAuthenticate(BIOMETRIC_STRONG) before offering biometric login, and handle BIOMETRIC_ERROR_NO_HARDWARE and BIOMETRIC_ERROR_NONE_ENROLLED explicitly. For BIOMETRIC_ERROR_NO_HARDWARE, remove the biometric option from your UI entirely β don't grey it out, remove it. Fall back to your password/PIN flow without creating a confusing dead-end UX path. For BIOMETRIC_ERROR_NONE_ENROLLED, offer to deep-link the user to system Settings via Intent(Settings.ACTION_BIOMETRIC_ENROLL) β you can't enroll on their behalf, but you can get them there in one tap.
What happens to FIDO2 passkeys when the signCount mechanism breaks for synced credentials β and how do you handle it without locking users out?
This is the real production edge case most tutorials skip. When passkeys sync via iCloud Keychain or Google Password Manager, the signCount is explicitly set to 0 by the FIDO2 spec β because synced credentials share state across devices and a monotonic counter can't be consistently maintained. If you've implemented strict signCount validation, synced passkeys will fail every time on the second device (signCount 0 <= stored signCount 1). The correct production behaviour: if the received signCount is 0, skip the cloning check entirely β the spec permits this, and it means the authenticator doesn't support count tracking. Only trigger the clone-detection alarm when signCount > 0 AND the received value is less than or equal to your stored value. Implement this distinction explicitly β don't rely on library defaults.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.