Face Fingerprints

Developer reference for Certivant's face fingerprint fraud detection — the frozen ArcFace pipeline, the pgvector HNSW index, match thresholds and confirmation, the four fraud signals, tenant redaction, the super-admin Face matches page, consent, deletion, the backfill and calibration scripts, and installing pgvector on Windows and in production.

Face fingerprinting detects the same face on another verification, across all organizations. Same face + a different name or date of birth is treated as identity fraud: the request is forced to manual review (ESCALATED). It is never auto-rejected on this alone.

Pipeline

document OCR done (orchestrator)
  └─ enqueue BullMQ "face-index" job ─► worker: evaluateFaceMatches(requestId)
        ├─ fingerprintDocument(docId)   personal-ID face side + selfies
        │    └─ sidecar POST /represent  → detect → crop main face → align
        │         quality gate (blur, dark/bright, glare, < 80 px, confidence, pose)
        │         ok → 512-d L2-normalised ArcFace vector + modelId
        │         fail → no row, face_not_indexed { reasons }
        ├─ findFaceMatches(fingerprintId)   kNN, same modelId, all orgs,
        │                                   excludes same request / applicant, deletedAt null
        ├─ confirm likely matches with the full /verify pipeline
        │    (createBiometricProvider("deepface_local").matchFaces)
        └─ write KycFraudSignal rows (one per request pair, idempotent)
scoring step: if the job hasn't finished, evaluateFaceMatches runs inline (≤ 20 s);
              on timeout → face_not_indexed { reasons: ["timeout"] }, never blocks

Code: packages/kyc-engine/src/services/face-fingerprint.service.ts (engine), apps/worker/python/deepface_sidecar.py (/represent), packages/queue + apps/worker (the face-index queue).

Frozen model

One model only: ArcFace through DeepFace with a fixed detector, alignment, padding and normalisation. Every vector carries a modelId such as arcface@deepface-<ver>|det=retinaface|align=1|pad=<p>|v1, and vectors are only ever compared within the same modelId. Changing any part of the pipeline means a new modelId and a re-index (backfill) — never mix.

At start-up the sidecar embeds a bundled fixture twice; if the cosine similarity is below 0.9999 /health reports representSelfTest: "failed" and /represent returns 503.

  • Table face_fingerprint (FaceFingerprint): organization, request, applicant, document, kind (document_portrait | selfie), modelId, embedding vector(512), quality, keyed hashes of the name words / DOB / document number, deletedAt.
  • embedding is read and written only through $queryRaw / $executeRaw with a '[f1,…]'::vector literal (Prisma type Unsupported).
  • Index: CREATE INDEX face_fingerprint_embedding_hnsw ON face_fingerprint USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64) WHERE "deletedAt" IS NULL; Queries run SET LOCAL hnsw.ef_search = 100 and order by cosine distance <=>.
  • When the vector extension is missing (checked once via SELECT 1 FROM pg_extension WHERE extname='vector', cached) fingerprinting is disabled and a face_not_indexed signal with reason vector_unavailable is written — an orchestration never crashes.

Thresholds

SystemSetting face.matching (defaults): enabled: true, likelyDistance: 0.40, possibleDistance: 0.50, k: 10 (cosine distance).

DistanceConfirmed by /verifyIdentitySignal
≤ likelyyesname or DOB differsface_reused_different_identity (high) — review trigger
≤ likelyyessame name + same DOBface_seen_before (low)
≤ likelyno—face_possible_match (medium)
likely < d ≤ possible——face_possible_match (medium)
quality gate failed / no vector search / timeout——face_not_indexed (low)

Identity comparison uses keyed hashes only (HMAC with the platform PII key, the blind-index helper family in packages/database/prisma/lib/pii.ts): names are normalised (NFD, accents stripped, lower case, letters only), split into words, one-letter initials dropped, each word hashed. "Same name" = Jaccard ≥ 0.6 and (≥ 2 words in common or one name's words ⊆ the other's). DOB = HMAC of the ISO date; unknown on either side is null and never counts as "different".

face_reused_different_identity is a review trigger in the scorer and the default policy (forces ESCALATED, never a disqualifier), a policy/workflow fact, an audit event fraud.face_reused, and appears in webhook fraudIndicators (type + severity only).

Calibration

Run scripts/calibrate-face-thresholds.ts (read-only) once a few hundred faces are indexed: it prints distance histograms for same-applicant vs different-applicant pairs and suggests likelyDistance / possibleDistance. Change them in SystemSetting face.matching, not in code.

Signal details and privacy

KycFraudSignal.details: { crossTenant, distance, similarity, confirmedByVerify, differences[], modelId, matchedKind, otherOrganizationId, otherRequestId }. relatedRequestId = the other request.

otherOrganizationId, otherRequestId and — when crossTenant — the relatedRequestId are server-only. A tenant never sees another tenant's applicant (no name, picture, organization or request id).

Tenant redaction

One pure helper, packages/api/src/lib/fraud-signal-redaction.ts (dependency-free so the engine can copy it):

  • redactFraudSignalForTenant(signal, viewerOrgId, canSeeRequest?, publicId?) — cross-tenant → relatedRequestId: null, every server-only key removed (otherOrganizationId, otherRequestId, otherApplicantId, …, at any depth) plus any value equal to the foreign ids; same-tenant → server-only keys removed, relatedRequestPublicId only when the viewer may open it. A foreign otherOrganizationId counts as cross-tenant even without the flag.
  • redactAuditPayloadForTenant(eventType, payload, viewerOrgId) — fraud.* audit payloads.
  • redactScoreEvidenceForTenant(value) — scorer factors / verdictMeta.

packages/api/src/lib/fraud-signal-view.ts → redactFraudSignalsForViewer(signals, { viewerOrgId, userId, … }) adds the scope check: related requests are looked up inside the viewer's organization only (anything else is treated as foreign), team scope through getKycResourceAccess for session users, org-wide for API keys; on the dashboard it also returns the face-bearing document ids for the side-by-side preview (through the existing scope-checked GET /api/kyc/dashboard/:orgSlug/requests/:id/documents/:docId/url).

Applied at:

SurfaceRoute / place
Dashboard request detail (fraudSignals, auditLogs, riskScore)GET /api/kyc/dashboard/:orgSlug/requests/:id
Dashboard fraud-signals cardGET /api/kyc/dashboard/:orgSlug/requests/:id/fraud-signals
REST v1 detail (also what the MCP server reads)GET /api/v1/kyc/requests/:id
REST v1 fraud signalsGET /api/v1/kyc/requests/:id/fraud-signals
JSON results exportGET /api/kyc/dashboard/:orgSlug/requests/:requestId/export-results
Audit log list + CSVenrichAuditLogRows (/audit-logs, /audit-logs/export.csv)
WebhooksfraudIndicators carry type + severity only. Any future payload with signal details must go through redactFraudSignalForTenant(signal, organizationId) in the engine.

Rules for the engine: never put another organization's ids into an audit payload or a scorer factor without the details.crossTenant flag; never log embeddings, names or dates of birth.

Super-admin "Face matches"

/app/admin/face-matches (nav: admin tools) lists face_reused_different_identity and face_possible_match signals across all tenants with both sides: organization, request public id, applicant name, status, distance, differences, detection time; filters for severity, cross-tenant and date.

  • GET /api/admin/face-matches?severity=&crossTenant=&from=&to=&page=&limit=
  • GET /api/admin/face-matches/:id/portrait/:side (current | related) — streams the face-bearing document inline.

Both are super-admin only (requireSuperAdmin: legacy admins without an AdminTeamMember row, or role super_admin; a scoped admin role gets 403). Every portrait view writes face_match.portrait_viewed to the admin activity log before the bytes are sent; if the write fails the portrait is refused (503). Portraits only load when a row is expanded.

Every applicant flow asks for a separate purpose biometric_fraud_check ("Face check"), and the photos purpose says photos are also used to detect identity fraud. It is required by default. It is optional only when the non-biometric alternative is on (faceCheckSkippable, set on the form or in the body of POST /api/v1/kyc/requests and of the live-call scheduling API; for jurisdictions that require one, e.g. Québec). A refusal then skips the selfie and the face checks (no fingerprint is computed), and the request goes to manual review with the review reason biometric_consent_refused. There is also an organization setting (Settings → KYC/B → Verification checks, off by default), the fallback for links sent without a form. The first match wins: the API field when sent, then the form switch, then the organization setting. The flows that ask for the consent:

  • Public forms and the hosted /verify page — consentItemsFor() in apps/web/modules/saas/kyc/lib/applicant-consent.ts (shown whenever the flow captures photos; consent version 2026-10-26). The server keeps the purpose in _consent.purposes / the request.consent_recorded audit event (APPLICANT_CONSENT_PURPOSES in packages/api/src/lib/applicant-consent.ts).
  • Live ID verification — ConferenceConsentScreen renders the purposes the server lists; the list, the mandatory purposes and the version live in packages/kyc-engine/src/services/kyc-conference.service.ts (CONFERENCE_CONSENT_PURPOSES, CONFERENCE_CONSENT_MANDATORY_PURPOSES, CONFERENCE_CONSENT_VERSION). Guests / observers are not asked.

Deletion

Fingerprints are biometric data: every path that deletes documents (document-deletion.service tombstone, purge / retention, delete after decision, erasure, data-deletion requests) hard-deletes their face_fingerprint rows (deleteFingerprintsForDocuments / deleteFingerprintsForRequest). Signals that referenced a deleted request keep their row; the super-admin page shows "Request deleted".

Backfill

scripts/backfill-face-fingerprints.ts — dry-run by default, --apply to write; processes in batches and skips deleted documents. Run it after installing pgvector and applying the migration, and again after any modelId change.

npx tsx scripts/backfill-face-fingerprints.ts            # dry run
npx tsx scripts/backfill-face-fingerprints.ts --apply

Installing pgvector

The migration 20261023120000_face_fingerprint runs CREATE EXTENSION IF NOT EXISTS vector;, so the extension files must exist on the database server first. Until then fingerprinting stays disabled (vector_unavailable).

Windows (Laragon / local PostgreSQL)

  1. Install Visual Studio Build Tools with the C++ workload.

  2. Open the x64 Native Tools Command Prompt as administrator, then:

    set "PGROOT=C:\path\to\postgresql"   :: the server Laragon runs
    git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git
    cd pgvector
    nmake /F Makefile.win
    nmake /F Makefile.win install
    

    install copies vector.dll into %PGROOT%\lib and vector.control + vector--*.sql into %PGROOT%\share\extension.

  3. Restart PostgreSQL, then check: SELECT * FROM pg_available_extensions WHERE name = 'vector';

  4. Apply the migration (coordinator procedure: author with prisma migrate diff, migrate resolve --applied — never migrate dev).

Production

  • Self-hosted (Debian / Ubuntu, PGDG packages): apt install postgresql-<major>-pgvector, then restart PostgreSQL.
  • Managed PostgreSQL: enable the vector extension in the provider's console (most managed services ship it); the migration's CREATE EXTENSION then succeeds.
  • Verify with SELECT extversion FROM pg_extension WHERE extname = 'vector'; (HNSW needs pgvector ≥ 0.5.0), apply the migration, then run the backfill.
  • pgvector is required in production: without it no face is indexed and every request carries face_not_indexed / vector_unavailable.