Verification

Liveness

Passive, colour-flash and 3D-action liveness — the server enforces the configured method and it cannot be downgraded client-side.

The LIVENESS module proves there is a real, present human in front of the camera — defeating printed photos, screen replays, masks and deepfakes. It is a single module with a selectable method, configured on the workflow step (config.method). Liveness confirms a live human; it does not confirm the right human — that is Face Match, which nearly every real workflow runs alongside it.

Methods

Method (config.method)StyleHow it works
PASSIVENo user actionAnalyses facial attributes on a single frame (or a short burst with a static-injection motion check when the client supplies extra frames).
PASSIVE_VIDEONo user actionRecords a short selfie clip scored on the hosted video liveness engine; depth fusion defeats photos and replays.
FLASHINGColour flashProjects a random sequence of colours (red / green / blue / white) and checks the reflections off a real face.
3D_ACTIONChallenge–responseThe applicant performs a random sequence of actions (turn left, turn right, look up, look down, smile).
GESTUREChallenge–responseThe applicant performs random hand gestures on camera.

Passive methods run through the standard capture step. The active methods (FLASHING, 3D_ACTION, GESTURE) issue a server-generated, single-use challenge — the applicant's client fetches the random sequence and submits the captured response to a dedicated verify endpoint. Because the sequence is random and server-issued, a pre-recorded replay of an earlier session fails.

Server enforcement

The configured method is enforced on the server — it cannot be downgraded or skipped from the client:

  • The passive handler rejects a submission whose configured method is an active one (active_liveness_required) — active liveness has to arrive through the challenge/verify path, not as a passive frame.
  • If a workflow requests a method the tenant is not entitled to, the engine falls back to PASSIVE rather than dropping liveness entirely — a downgrade to no-liveness is never the outcome.
  • A required liveness module that never produced a terminal result forces the session to review, not approval — a skipped selfie can't pass as verified.

Info

Liveness runs once per session even if a workflow places the step more than once (it shares a single selfie capture). The verdict is mirrored onto any repeated liveness nodes so the session isn't stranded as incomplete.

Choosing a method

MethodApplicant frictionDefeats printed photoDefeats screen replayDefeats injection/virtual-camera attacksBest for
PASSIVENone⚠️ Partial⚠️ Partial (burst mode)Highest-conversion onboarding, low fraud pressure
PASSIVE_VIDEOVery low (short clip)⚠️ PartialSlightly stronger passive option with near-zero UX cost
FLASHINGLow (few seconds, no action)Middle ground — strong against printed photos and screens with minimal cognitive load
3D_ACTIONMedium (follow prompts)Account takeover, high-value accounts, injection-attack risk
GESTUREMedium (follow prompts)Same as 3D_ACTION; alternative UX for accessibility or camera-angle constraints
  • PASSIVE / PASSIVE_VIDEO for the highest conversion — the applicant barely notices it happened.
  • 3D_ACTION / GESTURE when fraud pressure is real: account takeover, high-value accounts, injection-attack risk. The random challenge defeats replays.
  • FLASHING as a middle ground — strong against printed photos and screen replays with almost no cognitive load.

Tip

A weak-but-genuine liveness signal on a capture problem (bad lighting, no face) is retryable — the applicant is asked to redo the capture rather than being declined outright. Only exhausted retries escalate to operator review.

How scoring and decisioning works

PASSIVE liveness produces a numeric score (0–100) that is compared against a per-workflow min_score (default 50 if the workflow doesn't set one). The terminal status is layered on top of that comparison, not just the raw number:

Capture-quality gate first

Blur, poor lighting, a face that's too small/large in frame, or multiple faces detected trips a quality gate before scoring even happens. This is retryable — charged against the same shared "selfie capture" budget as face match (KYC_MAX_ATTEMPTS, default 3 attempts) since one selfie feeds both modules. Exhausting retries routes to review, not decline.

Score vs. min_score

A passed capture whose score >= min_score and isn't otherwise flagged for review → approved. Below the floor → declined.

Burst / static-capture check (PASSIVE only)

When the client supplies extra frames, the engine takes the median score across up to 4 frames (a single lucky frame can't carry a weak result) and measures motion between them. Motion below a configured floor (KYC_BURST_MOTION_FLOOR, default 0.3) flags static_capture_suspected and forces review — a burst of identical stills usually means a virtual-camera injection attack, not a live face.

Fail-closed on engine unavailability

If the liveness engine can't be reached, the result routes to review, never a silent approval.

Active methods (FLASHING / 3D_ACTION / GESTURE)

These resolve through their own challenge-verification logic rather than the passive score/threshold path — a failed challenge (wrong action sequence, spoof detected, screen-replay suspected, sequence altered, timing anomaly) is a terminal decline, not retryable, because it's an attack signal rather than a capture-quality issue.

Note

The raw liveness score and min_score threshold are internal tuning — the caller-facing signal is the terminal status plus a reason/warning code when it isn't approved. This is deliberate: surfacing the exact numeric bar would let an attacker calibrate a spoof to just clear it.

Common decline / review reasons

Reason codeOutcomeWhat triggers it
active_liveness_requiredRejectedA passive frame was submitted for a workflow configured with an active method
spoof_detected / screen_replay_suspectedDeclinedActive-method challenge detected a photo, mask, or screen replay — terminal, not retryable
sequence_altered / timing_anomalyDeclinedThe challenge response doesn't match the server-issued random sequence, or timing is inconsistent with a live capture
static_capture_suspectedReviewBurst frames show near-zero motion between them (possible virtual-camera injection)
liveness_below_thresholdDeclinedPassive score fell below min_score on a clean, in-quality capture
Capture-quality issues (blur, lighting, no face, multiple faces)Retryable → review after exhausted attemptsSelfie couldn't be scored reliably
Liveness engine unreachableReviewFail-closed — never silently approved

Warning

Common integration pitfalls.

  1. Assuming a PASSIVE decline means the applicant is a fraudster — most passive declines are capture-quality issues that should have been caught by the retry loop; check whether retries were actually exhausted before treating a decline as a fraud signal.
  2. Requesting a liveness method the tenant isn't entitled to and expecting a hard error — you'll silently get PASSIVE instead. Confirm your tenant's entitled methods in the console rather than assuming a workflow config request always takes effect.
  3. Placing LIVENESS more than once in a graph workflow expecting two independent selfie captures — it shares one capture and mirrors the same verdict onto every node.

Sunglasses gate

By default liveness disallows sunglasses (config.disallow_sunglasses: true) — an ONNX classifier flags them so the applicant is asked to remove them, keeping the selfie usable for face match.

Camera requirements

Liveness needs camera access. In the web SDK, modal (iframe) mode requires the camera; microphone permissions policy (the SDK sets it and the flow serves it). On mobile browsers, camera in a cross-origin iframe is unreliable, which is why mode: "auto" opens a popup there. Behind a strict CSP, allow your Obsidian flow origin in frame-src, or use popup / redirect.

Result shape

LIVENESS returns a status and the method that ran, plus engine metadata. The raw liveness score threshold is an internal detail; the caller-facing signal is the terminal status. Read it from GET /v3/session/<id>/ or decision_v3.liveness_checks on the decision endpoint.

{
  "feature": "LIVENESS",
  "status": "approved",
  "score": 87.5,
  "warnings": [],
  "data": {
    "method": "PASSIVE",
    "engine": "thirdfactor-hosted",
    "age_gender": { "gender": "male" }
  }
}
curl -s "$TF_BASE_URL/v3/session/$SESSION_ID/" \
  -H "x-api-key: $TF_API_KEY" \
  | jq '.results[] | select(.feature == "LIVENESS")'
const session = await fetch(`${process.env.TF_BASE_URL}/v3/session/${sessionId}/`, {
  headers: { "x-api-key": process.env.TF_API_KEY! },
}).then((r) => r.json());
const liveness = session.results.find((r: any) => r.feature === "LIVENESS");
console.log(liveness.status, liveness.data.method);
import requests

session = requests.get(
    f"{BASE_URL}/v3/session/{session_id}/",
    headers={"x-api-key": API_KEY}, timeout=15,
).json()
liveness = next(r for r in session["results"] if r["feature"] == "LIVENESS")
print(liveness["status"], liveness["data"]["method"])

FAQ