We are building a brand-new, reusable React Native package (publishable npm library, not tied to any existing repo) that any host app can install to authenticate field personnel via facial recognition + offline liveness detection, running fully offline on standard mid-range phones. The package ships a TypeScript JS API + a native module (TFLite inference) + bundled open-source models, and behaves identically on Android and iOS. A bundled example app demonstrates and tests it.
Primary use case: field attendance / personnel check-in — so anti-spoofing must defeat photo/screen fraud.
Confirmed requirements:
- Liveness (hybrid): passive silent anti-spoof on every frame + a basic active challenge — the user is randomly prompted to blink, smile, or turn their head slightly to prevent attendance fraud via photographs or screens. Both run fully offline.
- Enrollment: templates are generated centrally on the server and synced to the device while online; matching happens 100% offline in the field.
- Sync & purge: authentication/attendance events are stored locally while offline, then synced to an AWS server when connectivity is restored, after which the local copy is purged.
- Security bar: high assurance — embeddings only (never raw images at rest), hardware-backed key storage, hardware attestation, template cancelability, signed model-integrity checks, and a tamper-evident audit log.
The hard part is not "detect a face" — it's doing accurate 1:1 (or 1:N) matching plus spoof rejection plus template protection, all on-device, offline, on cheap hardware, with one shared codebase.
Authentication pipeline (all on-device, offline):
Camera frame (VisionCamera)
│
▼
[1] Face detect + landmarks ── ML Kit face detector (frame processor)
│ (bbox, eyes/nose/mouth landmarks, head angle, face size/centering gate)
▼
[2] Quality gate ── reject blur / off-angle / too-small / multi-face / too-dark
│
▼
[3] Passive liveness ── Silent-Face-Anti-Spoofing (MiniFASNet, MobileNetV2-class) TFLite
│ spoof score ≥ threshold → REJECT (screen/print/replay)
▼
[4] Active challenge ── random prompt: BLINK / SMILE / TURN-HEAD
│ verified from FaceMesh landmarks: Eye-Aspect-Ratio dip→rise (blink),
│ mouth-aspect / lip-corner spread (smile), head yaw from landmarks (turn).
│ timeout / wrong response → REJECT
▼
[5] Face embedding ── MobileFaceNet TFLite → 128/512-d vector (CPU/XNNPACK)
│
▼
[6] Match ── cosine similarity vs locally-stored enrolled templates
│ ≥ threshold → AUTHENTICATED
▼
[7] Record attendance ── signed local event (hash-chained); queued for AWS sync, then purged
Templates are provisioned by the server (generated from enrollment photos server-side using the same embedding model), pushed to the device during an online sync, decrypted into memory only at match time.
All technologies below are open-source (MIT / Apache-2.0) with no per-seat or runtime license fee. Proprietary SDKs (Google ML Kit, Banuba, MiniAI, Face++) are deliberately avoided — we use only open models + open RN libraries so the full prototype source can be shared without licensing encumbrance.
| Concern | Library / Model | License | Why |
|---|---|---|---|
| Camera + frame processors | react-native-vision-camera + react-native-worklets-core | MIT | Real-time frame processors, runs JS worklets on frames, Android+iOS. |
| TFLite inference | react-native-fast-tflite | MIT (runtime: TFLite Apache-2.0) | JSI/zero-copy; defaults to XNNPACK CPU (no GPU needed); GPU delegate is optional acceleration. |
| Face detection | BlazeFace .tflite (from MediaPipe) |
Apache-2.0 | Tiny (~0.5 MB) face detector → bbox; runs fast on CPU. |
| Landmarks (gate + challenge) | MediaPipe Face Mesh / Face Landmark .tflite |
Apache-2.0 | 468 landmarks → eye/mouth points + head pose; no proprietary SDK. |
| Passive liveness model | Silent-Face-Anti-Spoofing (MiniFASNet) .tflite |
Apache-2.0 | Tiny (<2 MB), RGB single-frame, ~98% PAD, mobile-grade. |
| Embedding model | MobileFaceNet .tflite (open weights) |
Apache/MIT | ~1–4 MB, mobile-designed, 128/512-d embeddings, strong accuracy on CPU. |
| Active challenge signals | Derived from Face Mesh landmarks | — | Eye-Aspect-Ratio (blink), mouth/lip-corner geometry (smile), landmark-derived yaw (turn) — no extra model. |
| Local secure store | react-native-mmkv (with encryption key) | MIT | Fast key-value store, AES-encrypted at rest; holds templates + queued attendance events. |
| Network status | @react-native-community/netinfo | MIT | Detect connectivity restore to trigger AWS sync. |
| AWS sync | axios → AWS API Gateway / app endpoint | MIT | Push queued attendance events; purge on confirmed receipt. (Avoid heavy aws-sdk; plain HTTPS + token.) |
| Key management | react-native-keychain → Android Keystore / iOS Keychain | MIT | Hardware-backed AES key (StrongBox/Secure Enclave when present; falls back gracefully on 3GB devices). |
| Hardware attestation | Play Integrity API (Android) + DeviceCheck/App Attest (iOS) | Free platform API | Device/app integrity, verified server-side during provisioning sync (online only). |
One embedding model used everywhere (server enrollment + device matching) — vectors are only comparable if produced by the identical model + preprocessing.
Deliverable: the full source of the RN package + example app + bundled open
.tflitemodels (with provenance/licenses) shared as a repository; no closed binaries, publishable to npm.
A library, not an app. Built with create-react-native-library (TurboModule template) so it ships a
JS/TS API, a native module, and an example/ app for dev + manual testing.
react-native-offline-face-auth/
├── package.json # name, peerDeps, files whitelist (lib/, android/, ios/, models/)
├── react-native-offline-face-auth.podspec
├── src/ # ── public + internal TS, compiled to lib/ ──
│ ├── index.ts # PUBLIC API barrel (the only supported entry point)
│ ├── FaceAuthView.tsx # <FaceAuthView> camera component (VisionCamera-based)
│ ├── useFaceAuth.ts # hook: start/cancel auth, status, result callbacks
│ ├── pipeline.ts # orchestrates detect→gate→liveness→challenge→embed→match
│ ├── detector.ts # BlazeFace + FaceMesh TFLite (bbox + landmarks)
│ ├── qualityGate.ts # blur/angle/size/lighting/multi-face checks
│ ├── liveness.ts # MiniFASNet TFLite (passive) inference + threshold
│ ├── activeChallenge.ts # blink/smile/head-turn state machine (landmark-driven)
│ ├── embeddings.ts # MobileFaceNet preprocessing + inference
│ ├── matcher.ts # cosine similarity, threshold, 1:1 / 1:N
│ ├── secure/
│ │ ├── keystore.ts # hardware-backed key (drives MMKV encryption key)
│ │ ├── templateStore.ts # encrypted MMKV CRUD for enrolled templates
│ │ ├── cancelable.ts # biohashing / revocable template transform
│ │ └── auditLog.ts # append-only signed attendance/auth events
│ ├── sync/
│ │ ├── provisioning.ts # online: attest device, pull encrypted templates
│ │ ├── attendanceSync.ts # push queued events to AWS, then purge local
│ │ ├── netStatus.ts # NetInfo watcher → triggers sync on reconnect
│ │ └── modelIntegrity.ts # verify .tflite sha256 vs signed manifest on boot
│ ├── config.ts # FaceAuthConfig type + sane defaults (thresholds, endpoints)
│ └── types.ts # public TS types (AuthResult, Challenge, SyncStatus, …)
├── android/ # native module: TFLite frame-processor plugin, keystore
├── ios/ # native module: TFLite frame-processor plugin, keychain
├── models/ # bundled .tflite files + sha256 manifest + LICENSES
└── example/ # demo app: enroll-status, run auth, attendance log, sync
Internal modules are not exported; only src/index.ts is the public surface.
// 1. Initialize the SDK once (loads models, opens encrypted store, starts net watcher)
await FaceAuth.init(config: FaceAuthConfig);
// config: { awsSyncUrl, deviceToken, thresholds?, challenges?, syncIntervalMs?, purgeAfterSync? }
// 2. Provision templates from server while online (idempotent, resumable)
await FaceAuth.provision(); // attests device, pulls encrypted templates → local store
// 3. Drop-in UI component to run an authentication
<FaceAuthView
mode="identify" | "verify"
personnelId={id} // required for verify (1:1)
onResult={(r: AuthResult) => …} // { ok, personnelId?, matchScore, livenessScore, challengePassed }
onGuidance={(g) => …} // "center face", "blink now", etc.
/>
// 3b. Or headless via hook for custom UI
const { start, cancel, status } = useFaceAuth({ mode, personnelId, onResult });
// 4. Attendance + sync (also runs automatically on reconnect)
FaceAuth.getPendingCount();
await FaceAuth.syncNow(); // push queued events to AWS, purge acked rows
FaceAuth.onSyncStatus(cb); // subscribe to SyncStatus changes
// 5. Lifecycle
await FaceAuth.dispose();Peer dependencies (host installs these): react-native-vision-camera, react-native-worklets-core,
react-native-fast-tflite, react-native-mmkv, react-native-keychain,
@react-native-community/netinfo. The package declares them as peerDependencies so the host controls
versions and there's no duplication.
npx create-react-native-library@latest react-native-offline-face-auth(TurboModule + view template). Produces thesrc/+android/+ios/+example/layout above.- Declare the open-source RN libs as peerDependencies; install them inside
example/for dev. - Set min targets in the podspec/gradle: Android
minSdkVersion = 26(8.0), iOS deployment target12.0. - Document required host config (camera permission strings, VisionCamera frame-processor setup) in README.
- fast-tflite: default to XNNPACK CPU delegate (works on every 3GB device, no GPU required); detect and use GPU delegate only as optional acceleration where available.
detector.ts: load BlazeFace + Face Mesh.tflitevia fast-tflite; in the VisionCamera frame processor (throttled ~5–10 fps) run BlazeFace → bbox, then Face Mesh → 468 landmarks.- Compute head pose (yaw/pitch/roll) and feature points from landmarks (no proprietary detector).
qualityGate.ts: accept a frame only if exactly one face, bbox covers target % of frame, head pose within bounds, brightness in range, sharpness above threshold. Drives on-screen guidance ("center your face", "move closer").
- Add MobileFaceNet
.tflitetosrc/models/; load via fast-tflite (XNNPACK CPU). embeddings.ts: crop to face bbox, align (use eye landmarks), resize to model input (e.g. 112×112), normalize exactly as the server pipeline does, run inference → L2-normalized vector.- Validate parity: same face image through server pipeline and device pipeline must yield cosine similarity ≈ 1.0. This parity check is the make-or-break step.
- Add MiniFASNet
.tflite;liveness.tsruns it on the (optionally multi-scale cropped) face region. - Reject if spoof probability ≥ tuned threshold. Run before matching to fail fast on spoofs.
- Tune thresholds against a test set of print/screen/replay attacks; expose as remote-config-style constants provisioned during sync (so they can be tightened without an app update).
activeChallenge.ts: a small state machine that, on each auth, picks a random challenge (BLINK / SMILE / TURN-HEAD) and a randomized order if multiple are required.- Verification is computed from Face Mesh landmarks (no extra model, fully offline):
- Blink: Eye-Aspect-Ratio (EAR) from eye landmarks goes high → low → high in a time window.
- Smile: mouth-aspect / lip-corner spread ratio rises above threshold.
- Turn head: landmark-derived yaw crosses a target (e.g. > +15° then back to center).
- Enforce a timeout and require the live face to remain the same tracked face throughout (prevents swapping a photo mid-challenge). Wrong/late response → REJECT.
- Defense-in-depth: passive model rejects static screens/prints instantly; the active challenge defeats high-quality replay and is the visible anti-fraud step personnel perform.
matcher.ts: cosine similarity of probe embedding vs each enrolled template; authenticate on best match ≥ threshold (1:N) or against the claimed identity (1:1). Calibrate threshold (e.g. ~0.6–0.7 for MobileFaceNet) for target FAR/FRR using a validation set.- Optional: average several gated frames' embeddings to reduce variance.
keystore.ts: create/fetch a hardware-backed AES-256 key (Android KeystoreStrongBoxif available / iOS Secure Enclave). Key never exported.templateStore.ts: store embeddings only (no raw face images, ever) in an encrypted MMKV instance whose encryption key comes from the hardware-backed key; load into memory only during a match, zeroize after.cancelable.ts: apply a revocable transform (biohashing / random-projection keyed per-tenant) so a stolen template is useless elsewhere and can be reissued by rotating the key.auditLog.ts: append-only, hash-chained, signed local log of every auth attempt (timestamp, result, liveness score, match score, device id) — uploaded opportunistically on next sync.modelIntegrity.ts: on every boot, verify each.tflitefile's SHA-256 against a server-signed manifest; refuse to run if tampered.
provisioning.ts: when online, (a) run Play Integrity / App Attest and submit the attestation token to the server, (b) on success, pull this device's roster of encrypted enrolled templates + threshold config + signed model manifest, (c) store locally encrypted.- Server generates templates from enrollment photos using the identical embedding model & preprocessing.
- Sync is incremental and resumable; after first successful sync the device authenticates indefinitely offline. Define template TTL / forced re-sync window for revocation hygiene.
- Every successful (or failed) auth writes an attendance event to the encrypted local queue: personnel id, timestamp, geo (optional), liveness score, match score, device id, challenge passed — embeddings/images are never uploaded, only the decision record. Each event is hash-chained + signed.
netStatus.tswatches connectivity via NetInfo. On reconnect (or periodic timer),attendanceSync.ts:- Batches queued events and POSTs to the AWS endpoint (API Gateway → Lambda/app, TLS, device auth token from provisioning).
- On a confirmed 2xx + server ack for a batch, purges those local rows (delete from the encrypted DB). Unconfirmed events stay queued and retry with backoff (idempotency key per event so retries don't double-count attendance).
- Sync is transactional per-batch: purge only what the server acknowledged.
- Result: device works fully offline; the moment it has network, attendance flushes to AWS and local data is cleared, minimizing data-at-rest on field devices.
- Finalize
src/index.tsAPI (init,provision,FaceAuthView,useFaceAuth,syncNow,getPendingCount,onSyncStatus,dispose) and theFaceAuthConfig/ result types. <FaceAuthView>exposes live guidance overlay, success/failure states, retry, and a configurable fallback signal (host renders PIN/passcode) when auth fails N times or no template is provisioned.- Build the example app: enroll-status, run identify/verify auth, view local attendance queue, trigger sync, and a diagnostics panel (model versions, last sync, attestation status, FAR/FRR harness).
- Write README: install, peerDeps, host config, API reference, model provenance + licenses.
Phases 0–7 are implemented and verified in TypeScript: the full offline pipeline (detect → gate →
liveness → challenge → embed → match), security layer, provisioning, and AWS sync/purge all exist and
the core math is unit-verified. What is missing is device integration — the package cannot yet
authenticate a real face on a phone because (1) nothing converts live camera frames into the RgbFrame
the pipeline consumes, and (2) example/ is only an App.tsx, not a runnable RN project, and there is
no automated test suite. This follow-up covers exactly those two gaps.
Chosen approach for (a): JS-thread inference. A Vision Camera frame-processor worklet uses
vision-camera-resize-plugin to extract a downscaled RGB buffer, marshals it to the JS thread, where the
existing detector.ts / pipeline.ts run the TFLite models via react-native-fast-tflite. This adds
zero new native code, reuses everything already built, and keeps session state on the JS thread where
useFaceAuth already lives. Frames are throttled to ~5–10 fps to fit the 3 GB CPU budget.
Goal: replace the placeholder global.__faceAuthExtract seam in
src/FaceAuthView.tsx with a real worklet that feeds
the pipeline.
- Add peer dep:
vision-camera-resize-plugin(MIT, mrousavy) inpackage.jsonpeerDependencies; document in README. - New file
src/frameSource.ts— auseFrameSource(onRgbFrame)hook that:- builds the resize-plugin (
useResizePlugin) and a throttle (tracklastRuntimestamp in the worklet), - in the
useFrameProcessorworklet, callsresize(frame, { scale: { width: W, height: H }, pixelFormat: 'rgb', dataType: 'uint8' })to get aUint8Array, - marshals
{ data, width, height }to JS viauseRunOnJS(worklets-core), invokingonRgbFrame(rgb). - Pick W×H so the largest model input (FaceMesh 192) is satisfied without over-sampling (e.g. 256×256);
the existing
resampleToSquareinsrc/detector.tshandles per-model crops from there.
- builds the resize-plugin (
- Refactor
FaceAuthView.tsx:- drop the
__faceAuthExtractglobal and theFrameExtracttype, - on each JS-thread
RgbFrame, rundetectFace(frame, { blazeface, faceMesh }, thresholds.detectScore)fromdetector.tsto getDetectedFace[], then call the hook'sfeed(frame, faces). - This means
FaceAuthViewnow needs the detector models too — extendPipelineModelsconsumption: pass{ blazeface, faceMesh, embedding, liveness }. Updatesrc/pipeline.tsPipelineModelsto carry the detector models (or add a separateDetectorModelsprop on the view) and thread them through.
- drop the
useFaceAuth.feedalready exists and runs the session on the JS thread — reuse unchanged.- Throttling + back-pressure: skip new frames while a
feedis mid-flight (aprocessingref) so a slow embedding pass on a 3 GB device can't queue up frames. - Model loading stays in the host (example) via
useTensorflowModel— already shown inexample/App.tsx; update it to also load BlazeFace + FaceMesh into the detector models and pass them to the view.
Files: src/frameSource.ts (new), src/FaceAuthView.tsx, src/pipeline.ts, src/index.ts
(export DetectorModels type), package.json, example/App.tsx, README.md.
Note: this is JS-thread inference by design. If profiling on a 3 GB device misses the latency targets, the documented future optimization is to move
model.runSync+ decode into the worklet — but that is explicitly out of scope here.
Follow the standard react-native-builder-bob / create-react-native-library example wiring so the
example consumes the package source directly (hot-reloads on edits):
- Scaffold a bare RN app in
example/(npx @react-native-community/cli initthen keepApp.tsx), or add the missing files by hand:example/package.json,example/index.js,example/app.json,example/metro.config.js,example/babel.config.js,example/react-native.config.js, plus the generatedexample/android/andexample/ios/projects. - Link the package to source:
metro.config.jswatchFoldersincludes the repo root +extraNodeModulesmapsreact-native-offline-face-auth→../src;babel.config.jsusesbabel-plugin-module-resolverwith the same alias (the bob convention). This lets the example import the live TS without a build. - Install the peer deps in
example/: vision-camera, worklets-core, fast-tflite, vision-camera-resize-plugin, mmkv, keychain, netinfo (+react-native-get-random-valuesto back the CSPRNG used insrc/secure/keystore.ts). - Add the iOS
NSCameraUsageDescription+ AndroidCAMERApermission and the VisionCamera/worklets babel plugin to the example's native config. - Drop placeholder/real
.tflitefiles intomodels/and fill the SHA-256 fields inmodels/manifest.jsonso the example boots (the integrity check currently fails on empty digests by design).
Add jest + babel-jest (or ts-jest) + @types/jest to root devDependencies, a jest.config.js,
and a jest.setup.js that mocks the native peer modules. Tests cover the pure logic I built:
__tests__/sha256.test.ts— empty / "abc" / 56-char / HMAC known vectors (already validated manually).__tests__/cancelable.test.ts— transform preserves cosine geometry (Δ<0.05) and cross-key isolation.__tests__/matcher.test.ts—identifypicks the right id;verifyaccepts true / rejects false.__tests__/qualityGate.test.ts— no_face / multiple / too-small / off-center / pose / lighting branches.__tests__/activeChallenge.test.ts— blink (EAR dip→rise), smile, turn pass; timeout + lost-tracking fail.__tests__/pipeline.test.ts— feed synthetic frames/faces throughFaceAuthSession; assert stage transitions andAuthResultfor happy path, liveness-fail, challenge-fail, no-template, no-match.__tests__/auditLog.test.ts— append → chain links →verifyChaintrue; tamper → false;purgedrops acked ids. (Mockreact-native-mmkvwith an in-memory Map andreact-native-keychain.)__tests__/attendanceSync.test.ts— mock axios + auditLog: only acked ids purged; partial-ack leaves remainder; re-entrancy guard. (syncAttendanceinsrc/sync/attendanceSync.ts).
Wire npm test (already declared) and add npm run typecheck against the full graph once example peer
deps resolve the React-layer files.
Files: jest.config.js, jest.setup.js, __mocks__/ (mmkv, keychain, netinfo), src/__tests__/*,
package.json (devDeps), example project files listed above.
- Tests (here, no device):
npm installthennpm test— all suites green;npm run typecheckclean. - Frame pipeline (device): run
example/on an Android 8 / 3 GB device and an iPhone; confirm the guidance overlay reacts (centering/lighting), the active challenge prompts, and a provisioned face authenticates while in airplane mode. - Sync/purge (device): queue events offline, re-enable network, confirm
POST /attendance, server ack, and local purge; kill network mid-batch and confirm no double-count. - Perf: log per-stage timings on the 3 GB device against the targets below.
- Android 8.0+ (API 26), iOS 12+, ≥ 3 GB RAM — no GPU required.
- Inference path defaults to XNNPACK CPU; multi-threaded, sized for these devices.
- Keep all model inputs small (112×112 embed, BlazeFace 128×128) to fit RAM/compute budget.
- Hardware-backed keystore: use StrongBox/Secure Enclave when present, fall back to standard Keystore/Keychain on older 3GB devices (still hardware-backed on Android 8+).
- Face detect (BlazeFace): <25 ms/frame, throttled.
- Liveness inference (MiniFASNet): <40 ms.
- Embedding inference (MobileFaceNet, XNNPACK): <80 ms.
- Active challenge: ~1.5–3 s (one prompt, user-paced).
- End-to-end decision: <4 s including the active challenge + a few gated frames.
- Total model footprint: <10 MB (BlazeFace + Face Mesh + MiniFASNet + MobileFaceNet combined).
- Server/device embedding mismatch → identical model + byte-identical preprocessing; automated parity test in CI (Phase 2). Highest-priority risk.
- Low-end / no-GPU performance → XNNPACK CPU is the default path; small inputs, throttled frames,
quantized (int8)
.tflitevariants if needed to hit latency on 3GB devices. - Liveness false rejects in poor field lighting → quality gate enforces lighting; thresholds tunable via provisioning, not hardcoded.
- Model theft / tampering → signed SHA-256 manifest + integrity check at boot; consider model
obfuscation/encryption-at-rest of the
.tfliteif threat model demands. - iOS Secure Enclave vs Android Keystore API differences → thin
keystore.tsabstraction over react-native-keychain; test StrongBox/Enclave presence per device.
- Parity test (CI): server vs device embedding cosine ≈ 1.0 for the same image set.
- Accuracy: measure FAR/FRR on a labeled validation set; tune match threshold.
- Liveness PAD: run a print/screen/replay attack set; report spoof-acceptance rate (target ~0%).
- Active challenge: verify blink/smile/turn are reliably detected and that a static photo or a pre-recorded video held to the camera fails the challenge.
- Offline test: airplane mode after one sync — full enroll-roster auth must work; attendance events accumulate locally.
- Sync & purge: re-enable network — confirm queued attendance events POST to AWS, server acks, and local rows are purged; killing network mid-batch must retry without double-counting (idempotency).
- Min-spec device: validate the full flow on an Android 8.0 / 3GB device and an iOS 12 device, CPU-only, meeting the latency targets above.
- Licensing: confirm every dependency + model is MIT/Apache (or platform-free API) and that the package can be published with no additional license obligations.
- Package integration: in a clean host app,
npm installthe package + peerDeps, call the public API, and confirm a full enroll→auth→sync flow works end-to-end (the example app is the reference host). - Cross-platform: identical thresholds/results on a mid-range Android (e.g. 2023 ~$200 device) and a base iPhone SE-class device.
- Security: confirm no raw images at rest, key is hardware-backed (not extractable), audit log is
hash-chained, integrity check rejects a modified
.tflite. - Perf: measure per-stage latency on target hardware against the targets above.