FaceGateBack to home

Security

Face is the password. That only works if the system can tell a live person from a photo, keep one customer's faces away from another's, store biometric data it can't turn back into a picture, and hand out sessions that expire. This page documents how FaceGate does each of those — and what it does not yet do.

Two-layer liveness

A face match alone proves nothing — a printed photo matches too. FaceGate runs liveness in two layers, and the server is the layer that decides.

Layer 1 — client challenge. The <FaceGate /> component runs an active challenge (blink, turn, nod) and watches motion across frames before anything leaves the browser. This blocks the laziest attacks instantly, with no round-trip.

Layer 2 — server validation. The browser's verdict is never trusted on its own. The server validates against AWS Face Liveness, which returns a Confidence score from a sequence of captured frames. FaceGate treats a session as live only at Confidence >= 90:

// providers/rekognition.ts — getLivenessResult()
const confidence = result.Confidence ?? 0;
const isLive = confidence >= 90;

Each liveness session is single-use. Before a verification or enrollment is accepted, the server checks whether that liveness_session_id was already consumed and rejects replays:

// core/face/liveness.ts — isSessionConsumed()
const rows = await this.db`
  SELECT 1 FROM sessions
  WHERE liveness_session_id = ${livenessSessionId}
  AND tenant_id = ${tenant_id}
  LIMIT 1
`;

A captured liveness verdict can never be re-played into a second login.

Anti-spoofing

On top of AWS Face Liveness, FaceGate runs its own attribute-based anti-spoof pass over the captured frames (core/face/anti-spoof.ts). This is defense-in-depth: it looks for the physical tells of a photo, a screen, or a video held up to the camera.

SignalWhat it catchesTrigger
static_poseA held-up photo doesn't move.3+ frames with yaw/pitch/roll variance < 0.5
low_sharpnessRe-photographed images lose detail.any frame with sharpness < 25
uniform_brightnessA screen emits flat, even light.3+ frames, brightness variance < 1 and average > 90
face_occludedA partially covered face.FaceOccluded true with confidence > 80

Each signal carries a severity weight (for example reflection_detected is weighted 2.0, texture_anomaly 1.5). If any signal fires, the frame set is flagged not-live and an overallConfidence is computed from the weighted signals. A clean pass returns isLive: true at 100.

A second, single-frame check (evaluateSingle) runs on the AWS Liveness reference image, adding a sunglasses check on top of occlusion and sharpness. So the reference image that AWS hands back gets its own independent screening before it's used to match.

Face data: vectors, not photos

FaceGate stores face vectors, not images. A photo is sent to the matching backend (AWS Rekognition), converted into a mathematical template, and the original image is discarded. What persists is a face_provider_id pointing at a vector — and a vector cannot be reversed into a face.

This is also exactly what the user is told at consent time. The canonical consent text (core/consent/registry.ts) reads:

Your face geometry will be captured to create a mathematical template for authentication.
No photos are stored — images are processed then immediately discarded.
Your template is encrypted and retained for up to 3 years from your last authentication.
You can request deletion at any time.

Per-tenant isolation

There is no global face pool. Every tenant has its own Rekognition collection (tenant.rekognition_collection_id), and matching only ever searches within the calling tenant's collection. One customer's faces are never matched against another's.

Tenant scoping is enforced at the data layer too. Every user, enrollment, session, and consent query is filtered by tenant_id, and the tenant is resolved from the API key on each request:

// api/middleware/auth.ts
const API_KEY_PATTERN = /^fg_(live|test)_[a-zA-Z0-9]{20,}$/;
// tenant looked up by api_key_live OR api_key_test, must be status 'active'

Live and test keys are distinct (fg_live_… vs fg_test_…), so test traffic can't touch live collections.

Consent and the right to erasure

FaceGate ships a consent flow and a deletion path built for BIPA and GDPR, not bolted on after.

Recording consent. POST /v1/consent records that a subject accepted a specific, versioned consent text. The server validates the submitted consent_version is known and that its consent_text_hash matches the canonical SHA-256 of that version — a client can't claim consent to text that doesn't exist:

// api/v1/consent.ts
if (deps.consentRegistry.getHash(consent_version) === undefined)
  return reply.status(400).send({ error: 'INVALID_CONSENT_VERSION: unknown consent version' });
if (!deps.consentRegistry.isValidHash(consent_version, consent_text_hash))
  return reply.status(400).send({ error: 'INVALID_CONSENT_HASH: hash does not match consent text for this version' });

The record stores tenant_id, consent_version, consent_text_hash, IP, user agent, and timestamp. Consent versions are controlled by code deployment, not runtime config, because each version requires legal review before it goes live.

Erasure. Two paths delete a user's biometric data completely:

EndpointReasonEffect
DELETE /v1/users/:iduser_request / tenant_requestRemoves the user and all face data
DELETE /v1/consent/:idconsent_revokedRevokes consent and deletes the linked user (if any)

Deletion (core/users/delete-user.ts) removes every enrolled face from the provider (with one retry per face), deletes face_enrollments, sessions, and the users row, and writes a deletion_audit_log entry recording the reason, the face IDs removed, and whether provider removal was confirmed. Consent records themselves are not deleted — their user_id is set to NULL (orphaned) so the proof-of-consent trail survives for compliance while the biometric data is gone.

Retention. A retention sweep (core/retention/enforce.ts) runs the same deletion logic automatically. Users whose last authentication is older than the retention window (default 1095 days / 3 years, matching the consent text) are expired and erased, with a retention_expiry audit entry. Data isn't kept indefinitely just because no one asked to delete it.

Session security

A successful face verification mints a JWT. Tokens are signed with HS256 and carry an issuer of facegate, verified on every request (core/auth/jwt.ts).

TokenLifetimePayload includes
AccessaccessTtl (recommended 15 min)sub, tenant_id, role, session_id, confidence, device_fingerprint, challenge_hash
RefreshrefreshTtl (recommended 7 days)sub, tenant_id, session_id, device_fingerprint, device_id

The TTLs are config-driven (accessTtl / refreshTtl in seconds), with 15-minute access / 7-day refresh as the documented defaults. The access token carries the confidence of the match and the challenge_hash of the liveness challenge it came from, so the strength of the original authentication travels with the session.

Confidence threshold. Face matching only counts as a match at or above a configurable confidence floor. The default is 95% (FACE_MATCH_CONFIDENCE_THRESHOLD, config.ts); a tenant can raise it for higher-assurance deployments.

// config.ts
FACE_MATCH_CONFIDENCE_THRESHOLD: z.coerce.number().default(95),

Rate limiting. Security-sensitive routes are rate-limited per tenant (and per user when authenticated, falling back to IP), keyed as ${tenantId}:${userId || ip} (api/middleware/rate-limit.ts). Brute-forcing a face match is bounded.

What is not built yet (roadmap)

To be precise about the line between shipped and planned — the following are design goals, not current capabilities. Do not rely on them; they are not implemented.

CapabilityStatus
Duress detection (micro-expression analysis, duress gestures)Roadmap — Phase 5. Not implemented.
Mutual authentication visual markers (anti-phishing)Roadmap — Phase 2. Not implemented.
Behavioral biometrics (time/usage-pattern factors)Roadmap — Phase 5. Not implemented.
Threat-memory engine (cross-customer attack learning)Minimal. Do not treat as a shipped defense.
Self-hosted face matching (InsightFace/FaceNet)Roadmap — Phase 5. AWS Rekognition is the only live backend.

The anti-spoofing, two-layer liveness, vector-only storage, per-tenant isolation, consent/erasure, and session controls above are all built and tested today. Everything in this table is not.

Next steps

  • REST API — the consent, verify, users, and deletion endpoints in full.
  • Quickstart — wire up <FaceGate /> and mint a real session in a few minutes.