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.
Storage and search
- 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. embeddingis read and written only through$queryRaw/$executeRawwith a'[f1,…]'::vectorliteral (Prisma typeUnsupported).- 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 runSET LOCAL hnsw.ef_search = 100and order by cosine distance<=>. - When the
vectorextension is missing (checked once viaSELECT 1 FROM pg_extension WHERE extname='vector', cached) fingerprinting is disabled and aface_not_indexedsignal with reasonvector_unavailableis written — an orchestration never crashes.
Thresholds
SystemSetting face.matching (defaults): enabled: true,
likelyDistance: 0.40, possibleDistance: 0.50, k: 10 (cosine distance).
| Distance | Confirmed by /verify | Identity | Signal |
|---|---|---|---|
| ≤ likely | yes | name or DOB differs | face_reused_different_identity (high) — review trigger |
| ≤ likely | yes | same name + same DOB | face_seen_before (low) |
| ≤ likely | no | — | 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,relatedRequestPublicIdonly when the viewer may open it. A foreignotherOrganizationIdcounts as cross-tenant even without the flag.redactAuditPayloadForTenant(eventType, payload, viewerOrgId)—fraud.*audit payloads.redactScoreEvidenceForTenant(value)— scorerfactors/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:
| Surface | Route / place |
|---|---|
Dashboard request detail (fraudSignals, auditLogs, riskScore) | GET /api/kyc/dashboard/:orgSlug/requests/:id |
| Dashboard fraud-signals card | GET /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 signals | GET /api/v1/kyc/requests/:id/fraud-signals |
| JSON results export | GET /api/kyc/dashboard/:orgSlug/requests/:requestId/export-results |
| Audit log list + CSV | enrichAuditLogRows (/audit-logs, /audit-logs/export.csv) |
| Webhooks | fraudIndicators 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.
Consent
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
/verifypage —consentItemsFor()inapps/web/modules/saas/kyc/lib/applicant-consent.ts(shown whenever the flow captures photos; consent version2026-10-26). The server keeps the purpose in_consent.purposes/ therequest.consent_recordedaudit event (APPLICANT_CONSENT_PURPOSESinpackages/api/src/lib/applicant-consent.ts). - Live ID verification —
ConferenceConsentScreenrenders the purposes the server lists; the list, the mandatory purposes and the version live inpackages/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)
-
Install Visual Studio Build Tools with the C++ workload.
-
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 installinstallcopiesvector.dllinto%PGROOT%\libandvector.control+vector--*.sqlinto%PGROOT%\share\extension. -
Restart PostgreSQL, then check:
SELECT * FROM pg_available_extensions WHERE name = 'vector'; -
Apply the migration (coordinator procedure: author with
prisma migrate diff,migrate resolve --applied— nevermigrate dev).
Production
- Self-hosted (Debian / Ubuntu, PGDG packages):
apt install postgresql-<major>-pgvector, then restart PostgreSQL. - Managed PostgreSQL: enable the
vectorextension in the provider's console (most managed services ship it); the migration'sCREATE EXTENSIONthen 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.