Face Local SDK#
Documentação em português (Brasil)
A reusable React Native SDK with a UI-independent TypeScript core, a
Kotlin/Swift Expo module and a photo-library adapter. Face Local combines
liveness, automatic selfie discovery, Face Match and a structured result in
one SDK.
It is intended for integration into your applications; it is not published on npm.
private: true prevents accidental publication and does not prevent
installation from a local package.
Get the source code#
Get the package at facelocal.dev/en/download-sdk
with a one-time US$999 Stripe payment or a valid coupon.
After confirmation, download the .tgz archive and save your private recovery
code to restore download access in another browser.
Documentation and the demo APK remain public. Payment covers source-code access: there is no subscription, per-verification fee or online execution key for the SDK in your app. Own-brand use is allowed under the license; reselling the SDK is not permitted.
What it verifies — and what it does not#
- The engine receives camera frames and performs passive liveness plus movements.
- The image from that same approved capture creates the facial reference in memory. There is no API for turning a gallery selfie into an approved reference.
- The onboarding flow finds selfies and automatically compares authorized local photos.
- It returns a positive result when it finds a match, or a negative result after a completed search without a match. Failures, a lack of eligible photos and incomplete searches return an error or an inconclusive result, never an approval or a false negative.
JSON result with images and decision criteria (0.5+)#
To integrate the result into another app, use runFaceLocalVerification,
exported from @workspace/face-local-sdk/expo. It runs the automatic onboarding
flow and returns VerificationResult, with schemaVersion: "1.0":
status,approvedandreason: approved, not approved, inconclusive, cancelled or error;photos.livenessandphotos.compared: temporary local copies of the images actually used, never a new selfie or a file selected as the reference;faceMatch: original similarity, applied threshold and a 0–100 display index — not accuracy or the probability of identity;exif: Make, Model and DateTimeOriginal read from the same bytes analyzed natively;deviceFingerprint: the model profile used for capture, not a unique identifier;decision: engine, liveness, requirements, counts and exclusion reasons.
The compared image is the first one that meets the threshold; without a match, it is the best completed comparison, if any. No photos are returned on cancellation or error. Copies require explicit cleanup, expire and never modify the gallery.
Complete documentation:
The previous runFaceLocalOnboarding flow remains available, without image export.
Updating JavaScript alone is not enough for the new API: rebuild the native app.
Expo Go and browsers do not perform biometrics or simulate approval.
Structure#
- Main import: types, policy,
FaceLocalVerifier,runVerification, EXIF validation. /expo:createExpoEngine,photoPermission,managePhotoSelection,readLocalPhotos,runFaceLocalVerification,releaseFaceLocalVerification.android/: Face Local Android implementation, with CameraX, Expo bridge, liveness and local comparison.ios/: Face Local iOS implementation, with AVFoundation/SwiftUI, Expo bridge, ImageIO and Vision.app.plugin.js: permissions, Android backups and Android Release network isolation.tests/: deterministic flow tests. Test doubles are not included in the package.
Do not copy the app screens to reuse the SDK.
The Android implementation initializes MMKV for native configuration, but does not register embeddings or images in it. Native-library storage behavior still needs to be checked on a physical device.
Target compatibility#
| Component | Compatibility |
|---|---|
| Expo | SDK 57 |
| React Native | 0.86 |
| Android | API 26+; physical ARM device |
| iOS | 16.4+; physical iPhone |
| Face Local for Android | Kotlin, CameraX and Expo Modules |
| Face Local for iOS | Swift, AVFoundation and Expo Modules |
The SDK depends on Expo Modules, not Expo Go. A React Native project without Expo can install Expo Modules, follow the native configuration and use the same adapter. Other environments and versions require adaptation and validation; they are not automatically declared compatible.
Move it into another project#
From this workspace's root:
pnpm --filter @workspace/face-local-sdk build
pnpm --filter @workspace/face-local-sdk pack --pack-destination ../../generated-artifactsCopy the resulting .tgz into your other project. Install it from its local path:
pnpm add ./face-local-sdk-0.6.0.tgz
pnpm exec expo install expo-modules-core expo-media-library expo-image-pickerThe import name remains @workspace/face-local-sdk; no private registry is needed.
Model names in EXIF and Android#
Android's system model code and a photo's commercial model name may differ.
Only explicitly verified equivalences are accepted, not approximate matches.
The verified mapping includes Xiaomi 24117RN76E, 24117RN76G, 24117RN76L and
24117RN76O, device tanzanite, for Redmi Note 14, according to Google's
supported devices list.
This is not a universal equivalence for 5G, Pro or unknown regional variants.
CaptureDevice.model contains the resolved model; hardwareModel preserves the
system code when applicable. Validation still reads the native image bytes.
This identifies a model profile, not an individual device or authentic EXIF.
Diagnostics without photos#
ScanReport.device and summarizeVerification expose only the model/system code,
counts and missing-field diagnostics. They do not include photos, filenames,
URIs, dates, GPS, sessions, embeddings or scores.
The helpers neither store nor transmit data. Any diagnostic sharing must be
explicitly confirmed by the user, for an inconclusive result only; never share
the full verification JSON as a diagnostic log.
Configure the plugins in app.json:
{
"expo": {
"plugins": [
["expo-media-library", {
"granularPermissions": ["photo"],
"isAccessMediaLocationEnabled": false,
"photosPermission": "Allow access to all photos to find your face on this device."
}],
["expo-image-picker", {
"cameraPermission": "Use the camera for on-device liveness.",
"microphonePermission": false
}],
"@workspace/face-local-sdk"
]
}
}Important: the plugin disables Android backups and removes INTERNET from
the entire Android Release app, not just the biometric module. Review this
policy before integrating into an app that needs a network connection. Keeping
internet access requires a configuration review and a native-library audit;
do not silently remove the protection.
On iOS, ATS does not block all HTTPS traffic.
pnpm exec expo prebuild
# Physical Android device:
pnpm exec expo run:android --variant release --device
# Physical iPhone, on a Mac with Xcode and CocoaPods:
pnpm exec expo run:ios --configuration Release --deviceDo not run prebuild --clean over manual native changes without reviewing and
backing them up first.
Usage API#
Since 0.3, the native capabilities and capture identify the exact manufacturer and model, not a user-defined name, UUID or IDFV. Native code rechecks the EXIF before inference. An unknown model is rejected.
import { FaceLocalVerifier, requireFullPhotoAccess, type Consent } from "@workspace/face-local-sdk";
import {
createExpoEngine, photoPermission, readLocalPhotos,
} from "@workspace/face-local-sdk/expo";
import { requestCameraPermissionsAsync } from "expo-image-picker";
const engine = createExpoEngine();
const verifier = new FaceLocalVerifier(engine, {
matchThreshold: 0.83,
requireCaptureMetadata: true,
maxPhotos: 100,
captureTimeoutSeconds: 15,
});
if (!verifier.capabilities.available) {
throw new Error(verifier.capabilities.reason);
}
// Initial consent explains using the face to find photos on the device.
// Start with photoLibrary: false; authorize photos only after the OS responds.
const consent: Consent = consentFromYourUI;
if (!consent.camera || !consent.biometricProcessing) {
throw new Error("Consent is required.");
}
const camera = await requestCameraPermissionsAsync();
if (!camera.granted) throw new Error("Camera permission is required.");
try {
const receipt = await verifier.capture(consent);
// receipt is opaque session metadata. It contains no image or embedding.
const libraryPermission = await photoPermission(true);
requireFullPhotoAccess(libraryPermission);
verifier.authorizePhotoLibrary({
photoLibrary: libraryPermission.granted,
biometricProcessing: consent.biometricProcessing,
version: consent.version,
});
const library = await readLocalPhotos({
limit: 100,
signal: abortController.signal,
onProgress: count => updateLibraryProgress(count),
});
// Automatic comparison. Never use a library photo as the reference.
const report = await verifier.scan(library.photos, progress => {
updateProgress(progress.processed, progress.total);
});
showReportInYourUI(report);
} finally {
await verifier.revokeConsent();
}consentFromYourUI, abortController, the progress functions and
showReportInYourUI belong to the integrating app; they are not SDK exports.
Automatic onboarding without image export (previous API)#
import { runFaceLocalOnboarding } from "@workspace/face-local-sdk/expo";
import { requestCameraPermissionsAsync } from "expo-image-picker";
const result = await runFaceLocalOnboarding({
// Call only after visible consent to use the camera and face for comparison.
consent: { camera: true, biometricProcessing: true, photoLibrary: false, version: "1" },
signal: abortController.signal,
policy: { maxPhotos: 100, matchThreshold: 0.83, requireCaptureMetadata: true },
requestCameraAccess: async () => {
// Permission only; this does not open a still camera or an image picker.
if (!(await requestCameraPermissionsAsync()).granted) throw new Error("Camera not authorized.");
},
onPhase: phase => updatePhaseInYourUI(phase),
onSearchProgress: count => updateLibraryProgress(count),
onProgress: progress => updateProgress(progress.processed, progress.total),
});
// result.outcome: "positive" | "negative" | "inconclusive"
// result.reason explains why; result.report contains the actual comparisons.updatePhaseInYourUI also belongs to the integrating UI. The sequence is camera
permission → liveness → photo permission → discovery → Face Match.
There is no manual image selection. The operating system owns the limited-access
option; the app cannot remove or ignore it. Face Local requires full access.
With limited access, it asks the person to allow all photos in Settings and ends
the session without searching or comparing images.
Do not add search, compare or start-confirmation buttons after capture.
The Expo adapter uses the actual photo-permission response to continue on its own.
In the independent core, requestPhotoAccess must return
{ granted, accessPrivileges: "all" | "limited" | "none", canAskAgain? }.
Since 0.4, granted: true without an explicit scope and the old void return
do not authorize discovery, even with initial photo consent. Denial, cancellation
or expiry prevents subsequent reads; a permission response arriving after
cancellation is ignored.
On iPhone, the Selfies collection is identified by its PhotoKit subtype, independently of language. Album names provide additional clues, especially on Android. Discovery prioritizes those collections, then searches the remaining authorized photos without duplicates. A single-face image is a candidate; that does not prove it came from the front camera. No infallible origin classifier is claimed.
The limit is at most 1,000 photos per attempt, with a five-minute reference lifetime. The search stops at the first match. Reaching the limit while photos remain unexamined is inconclusive, not a negative result. Unavailable cloud photos, comparison failures and missing EXIF never produce synthetic scores. A negative result concerns only the eligible, authorized local photos examined.
Local capture protection#
- Only the native camera creates the reference. The bridge rejects additional input options; the legacy capture accepts a timeout, while the evidence API additionally accepts its TTL and threshold, never an external reference.
- Frames must have fresh, increasing timestamps, come from the built-in front camera and pass a single-use sequence gate.
- The iOS AVFoundation relay fails closed.
- Debug builds, simulators, detected root/jailbreak, debuggers and known hooks are blocked. Use a Release build.
- Android uses a secure window, rejects obscured touches and uses overlay protection on Android 12+. iOS cancels on screen recording or loss of focus.
- A security abort ends the entire attempt; there is no partial approval.
The receipt's source: "native-camera" is not cryptographic attestation.
Do not trust a hostile client simply because it returns this field to a server.
Local checks do not guarantee immunity to unknown root techniques, HAL/driver
manipulation or deepfakes. There is no external Play Integrity/App Attest
attestation, production-signing assurance, complete audit or attack-resistance
certification. The test APK uses a test signature. The iOS implementation has
been tested on an iPhone. When integrating the SDK or changing native capture,
validate the flow in your app, especially the camera relay.
Refer to the project's threat model and physical-device test protocol.
Cancellation and lifecycle#
- Use an
AbortControllerfor an in-progress photo scan. - Await
verifier.clear(); it waits for earlier callbacks to finish. - Call
revokeConsentwhen leaving the flow. - There is one native session per process.
- Android's capture Activity is not the same as leaving the app. iOS clears the session when the app moves into the background.
- Never put photos, EXIF, receipts or embeddings in AsyncStorage, logs or a server.
The JSON API explicitly exports up to two JPEGs into private cache for 30–300 seconds; templates remain in native memory. See the integration and cleanup guide.
Results#
| Result | Meaning |
|---|---|
match | One face and similarity at or above the threshold |
different | One face and similarity below the threshold |
inconclusive | Zero or multiple faces; not a non-match |
skipped | Cloud photo, forbidden URI or incomplete EXIF under the policy |
error | A file or result could not be processed |
The score is not a probability or certainty about identity.
report.nativeAttempts counts native calls, including failures and no-face
results. report.compared counts only valid single-face comparisons.
Photos excluded by metadata do not count as comparisons.
unreadable-photos and comparison-failed are distinct inconclusive reasons,
never substitutes for a negative result.
The default threshold, 0.83, has not been calibrated for your use case.
The reference is cleared on a scan error. Cancellation and expiry never turn
partial processing into a completed result.
Library and EXIF#
- Full photo permission is required; denial and revocation are respected.
- On iOS,
getAssetInfoAsyncusesshouldDownloadFromNetwork: false. - On Android, enumeration does not use
resolveWithFullInfo; native code reads Make, Model and DateTimeOriginal from MediaStore. It does not depend ongetAssetInfoAsync, which can requireACCESS_MEDIA_LOCATIONfor full EXIF and report an error even when full photo access was granted. - No location permission,
MANAGE_EXTERNAL_STORAGEor cloud transfer is used for the filter. - Permission scope is checked after discovery and during native processing. Revocation aborts the attempt and clears the reference.
- There is no
ph://or remote fallback. Android accepts only local MediaStore content, not remote providers. - GPS and other metadata are discarded. Make, Model and DateTimeOriginal are all required.
- Manufacturer/model matching normalizes only whitespace and case. It does not accept family names, prefixes, Pro/Max variants or approximate generations.
- Android uses
Build.MANUFACTURERandBuild.MODEL, with explicitly verified aliases. iOS resolves hardware locally through DeviceKit 5.9.0. Unknown models have no generic fallback. - The capture device is checked against capabilities, and native code rechecks EXIF from the same bytes used for inference, not just JavaScript metadata. Files over 32 MiB are rejected.
requireCaptureMetadata: falseis invalid; there is no UI switch to disable the filter. Unknown naming differences can exclude legitimate photos; equivalence must not be assumed.- This is not a unique fingerprint. Other physical devices share the same model, and EXIF can be edited. It does not prove origin, ownership or identity.
- Large libraries are processed in batches with an explicit limit.
- Edited or shared copies may lose metadata. This does not indicate fraud.
Replace the engine#
Implement BiometricEngine with capabilities, captureReference, compare
and clear. Capture must combine passive and active liveness, with a single
face, a reference tied to the approved capture and real native expiry.
Do not fabricate scores or compare embedding strings generically.
The current adapter calls evaluateFaceSimiByFeature on Android and
evaluateSimilarity(f1:f2:) on iPhone. Native-library internal formats are opaque.
To support the JSON API with images, also implement EvidenceEngine, validate
EXIF from the same inference bytes, and produce copies from the already analyzed
images instead of reopening the gallery. Do not advertise evidenceVersion: 1
without session binding, expiry, copy hashes and verifiable cleanup.
License and verification#
You may integrate Face Local into applications under your own brand (white-label). Selling or reselling the SDK is not permitted, including modified, repackaged or renamed versions. This restriction does not prevent you from selling your application that uses the SDK.
See the Face Local license, included in the package. The new terms do not change permissions for previously distributed versions. There is no anti-spoofing, forensic-evidence or legal-identity certification.
Run pnpm --filter @workspace/face-local-sdk test.
See docs/TESTES-EM-APARELHO.md in the workspace for the required physical-device tests.