Biometrics & Face Enrollment
During onboarding, users can enroll a reference selfie. A planned facial-recognition 2FA check will use it at sign-in. Today the app only captures and stores the selfie; no face matching runs yet.
Screen:
apps/mobile/app/(onboarding)/selfie.tsx(standard onboarding, step 3, optional). API:apps/api/src/router/biometricRoutes.ts→services/biometrics.ts.
Why this is treated differently
A selfie kept for face matching is biometric data. Several US state laws regulate it, among them Illinois BIPA, Texas CUBI and Washington's biometric law. They require:
- informed consent before collection;
- a stated purpose, and no use beyond it;
- deletion on request, or when the purpose ends.
The design follows those rules even where a given law wouldn't strictly apply.
Consent
- Nothing is captured until the user ticks the consent checkbox. The camera doesn't start until then.
- The upload must include
consent=trueand aconsentVersion, or the API answers400. - The API records
User.biometricConsentAtandUser.biometricConsentVersion. The version is theSELFIE_CONSENT_VERSIONconstant inselfie.tsx. Bump it whenever the notice text changes, so you can always tell which wording a user agreed to. - The step is optional. "Skip for now" continues onboarding, and those users simply can't use face 2FA.
Storage
Selfies use the same pipeline as creator ID documents. See Creator Onboarding & KYC → Government ID storage.
- Device: captured with
exif: falseon the front camera, re-encoded and capped at 1280px, and never saved to the camera roll. The local file is deleted after upload or retake. Screenshots are blocked on the screen. - API: multer memory storage, JPEG only, 10 MB maximum. The image is re-encoded by
sharp(lib/kycImage.ts), which drops all metadata. - At rest: AES-256-GCM envelope encryption, bound to the user id. The ciphertext goes to the private KYC bucket under an opaque
kyc/<uuid>key. It's aVerificationAssetrow withtype = "selfie". - One per user. Re-enrolling replaces the previous selfie and deletes the old object.
Purpose limitation
The selfie is used only for face verification:
- Creator review can't reach it. Every query in
services/creatorVerificationAdmin.ts(listing, viewing, rejecting) is scoped totype = "id_document". Admins can't view a selfie, and rejecting a creator doesn't delete one. - No endpoint returns the selfie image or a URL to it. The API exposes enrollment status only.
- Don't add other uses, such as profile photos, moderation or analytics, without new consent wording and a bumped consent version.
Retention and deletion
The selfie is kept until one of these happens:
| Trigger | Effect |
|---|---|
DELETE /biometrics/face (withdraw consent) | Row and object deleted; faceEnrolledAt and consent fields cleared |
| Re-enrollment | Previous selfie deleted |
| Account deletion | deleteUserAccount removes every VerificationAsset row and its bucket object |
Users withdraw consent themselves under Settings → Privacy → Face Verification (apps/mobile/app/(app)/(tabs)/me/settings/face-verification.tsx). The screen shows whether a selfie is on file and when consent was given. Withdrawing asks for confirmation, then calls DELETE /biometrics/face.
Re-enrolling isn't in settings yet
The capture screen lives in the (onboarding) stack, which redirects users who are already onboarded. Once the 2FA feature ships, add an enroll path from the Face Verification screen.
Endpoints
| Method | Path | Body | Purpose |
|---|---|---|---|
GET | /api/v1/biometrics/face | — | { enrolled, enrolledAt, consentAt } |
POST | /api/v1/biometrics/face | multipart selfie (JPEG), consent=true, consentVersion | Enroll or replace |
DELETE | /api/v1/biometrics/face | — | Withdraw consent and delete |
All require authentication. Writes are limited to 20 per hour per user. They return 503 when the KYC bucket or encryption key isn't configured: the routes fail closed, the same as creator onboarding.
Planned: face-verification 2FA
POST /verify-identity (controllers/auth.ts) answers 501 for now; it's reserved for the sign-in check. When it's built:
- Decrypt the reference selfie server-side and send the bytes to Rekognition (
CompareFaces, or Face Liveness and then compare). Never hand the stored blob or a URL to anything else. - Run
DetectFacesat enrollment to reject photos without exactly one clear face. Today enrollment doesn't check for a face at all. - It needs AWS credentials and an IAM policy limited to the Rekognition actions used. Don't use
IndexFaces/collections unless retention and deletion are extended to cover them. - Add an enrolled-user setting to turn the check on, and update this page.
