How the system protects biometric data, controls access, and meets privacy expectations — plus the threat model and what to configure for production.
- No raw images. Faces are converted to a 512-d embedding (an irreversible mathematical descriptor) and discarded. We never persist photos.
- Embeddings are encrypted at rest — both the template store and the search index.
- The audit log records actions, not faces (action, tenant, user_id label, outcome, time).
| Surface | Mechanism |
|---|---|
Web: templates (faces.db) |
Fernet (AES-128-CBC + HMAC-SHA256). Key from FACE_DB_KEY via PBKDF2 (200k iters, per-DB salt), or a generated .key file. (face/crypto.py) |
Web: search index (<db>/index/) |
Same cipher/key as the store — mat.npy, users.json, etc. are encrypted blobs, not readable plaintext. (face/index.py) |
| Android: embeddings | AES-256-GCM with a non-exportable Android Keystore key (hardware-backed where available). (android/.../data/Crypto.kt) |
Operational note: keep FACE_DB_KEY safe and backed up separately from the data —
without it, encrypted backups can't be decrypted.
- Integration API (
/v1) — every endpoint except/v1/healthrequires anX-API-Key. Keys are stored hashed (SHA-256); the raw key is shown once at creation. Each key has a role:admin— full control (enrol, delete, list, verify…)verify— recognition only (verify/identify/embed/compare); cannot write. Give browser/kiosk clients averifykey; keepadminkeys server-side. Keys carry akey_id, optional expiry, and per-key revoke. (face_service/keys.py,auth.py)
- Tenant self-service portal (
/portal) — companies sign in with a tenant-scoped password the admin sets and manage only their own keys, within their entitlement (separate signed session from the platform admin; ownership-checked revoke; disabled → 402). The platform admin grants access & limits; the tenant operates day-to-day — so a compromised platform admin can't fabricate access for others. (face_service/portal.py) - First-party app — verification is open (a walk-up kiosk); enrolment & management
require an admin login (operator accounts with PBKDF2-hashed passwords, or a bootstrap
FACE_ADMIN_PASSWORD). Sessions are signed, time-limited cookies (itsdangerous, keyFACE_SECRET_KEY). (face_service/admins.py,admin.py) - Tenant isolation — each key's data lives under its own tenant; no cross-tenant access.
- Entitlements (the access gate / paywall hook) — each tenant has
enabled,plan,max_keys, andallowed_roles. The admin sets these (the "green light"). Disabling a tenant makes every/v1call return402 payment_requiredimmediately; key creation refuses to exceedmax_keysor grant a role outsideallowed_roles. A future biller just flipsenabled. (face_service/tenants.py,auth.py)
The platform hosts a first-party app (/admin) and 3rd-party companies (/v1).
- Separate stores per tenant. Every
/v1request resolves storage toface_db/tenants/<tenant>/— its own encrypted SQLite DB and its own search index. The first-party app usesface_db/(root). One tenant's API key can only ever address its own users; the/admindata screens (People/Enrol) act on the first-party store only. - Per-tenant encryption keys.
crypto.get_cipher()runs per directory, so each tenant has its own.salt/.key— a distinct encryption key (even under a shared master passphrase, the per-tenant salt yields a different derived key). One tenant's exposure does not decrypt another's. - We store embeddings, not images. The enrolled photo is never persisted server-side — only the 512-d embedding (still biometric PII, so encrypted, but not the literal picture).
- Crypto-erase offboarding. Offboarding a tenant revokes its keys and deletes its store and its encryption key, making the data cryptographically unrecoverable.
- Host-trust reality (be honest with customers). In a managed deployment the operator controls the server and the encryption material at runtime (matching needs the key in memory) and can mint a key for any tenant. So app-level isolation is strong, but the host is inherently trusted. Mitigations: two-plane admin (manage access vs touch data), per-tenant keys, full audit, and crypto-erase. For zero host-trust, use the offline Android app — that data never leaves the device.
verify and compare responses include an HMAC-SHA256 signature over the outcome,
keyed by that tenant's signing secret, so a downstream app can detect a tampered/forged
response. SDKs verify it (fv.verify_signature(r)).
- Active head-turn challenge (default) — the user must perform a real 3D head turn;
a flat photo or a face on a screen can't. This is the primary defense. (
liveness_active.py, on-deviceLiveness.kt) - Passive single-shot anti-spoof (optional) — a MiniFASNet model that judges a single
frame. Off by default (untuned); enable with
FACE_LIVENESS=1(+FACE_LIVENESS_THRESHOLD) on self-hosted deployments for defense-in-depth. (liveness.py)
- Rate limiting per caller (API key or IP):
FACE_RATE_LIMIT/FACE_RATE_WINDOW; responses carryX-RateLimit-*, and 429s aRetry-After. (face_service/security.py) - Security headers on every response:
X-Content-Type-Options,Referrer-Policy,Permissions-Policy(camera), and aContent-Security-Policy: frame-ancestorsallowlist. - CORS is locked down: an origin may call
/v1only if it's inFACE_CORS_ORIGINSor registered by a tenant (admin console). The API key still scopes capability. - Idempotency keys prevent duplicate writes on retries (
Idempotency-Keyheader). - Request IDs (
X-Request-ID) on every response for traceable support.
- Data-subject access:
POST /v1/users/exportreturns what's held for a user (metadata — counts, dims, recent audit — not the raw template). - Right to erasure:
POST /v1/users/delete(one or many) andPOST /v1/users/purge(confirm:true, whole tenant). - Consent: obtain consent before enrolling people. (Operational responsibility.)
- Offline option: the Android app holds the
CAMERApermission only — noINTERNETpermission, so data physically cannot leave the device.
| Threat | Mitigation |
|---|---|
| Stolen disk / backup | Templates + index encrypted; key held separately (FACE_DB_KEY). |
| Leaked key file | API keys + operator passwords stored hashed; raw never persisted. |
| Photo/screen spoof | Active head-turn liveness (+ optional passive). |
| Enrolment by unauthorised user | Admin login / admin-role key required to enrol. |
| Look-alike false accept (1:N) | Identify requires the top to beat the runner-up by a margin. |
| Tampered verdict in transit | HMAC-signed results. |
| Brute force / scraping | Per-caller rate limiting + quotas. |
| Cross-customer data access | Per-tenant isolation throughout. |
| Template drift over time | Adaptive enrolment with permanent anchors. |
- Set
FACE_ADMIN_PASSWORD,FACE_SECRET_KEY,FACE_DB_KEY(strong, unique) — and back upFACE_DB_KEY. - Create named operator accounts (
manage_admins.py) so the audit shows who. - Issue
verify-role keys to integrators; reserveadminkeys for back-office. - Restrict
FACE_CORS_ORIGINS(or per-tenant origins) to known sites. - Serve over HTTPS (Caddy/HF/your proxy). Keep API keys out of public browser code.
- Back up the data volume (DB + keys + audit) regularly; store
FACE_DB_KEYseparately.