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"])

Liveness via API

There are three ways to run liveness from your backend. All three score on the same engines with the same verdict rules; they differ in who owns the capture screen and what gets kept.

Hosted sessionActive challenge / verifyPassive tool
EndpointsPOST /v3/session/ → applicant opens urlGET /v3/session/{id}/decision/POST /v3/tools/liveness/challenge/ → your UI → POST /v3/tools/liveness/verify/POST /v3/tools/liveness/
MethodsAll fiveFLASHING, 3D_ACTION, GESTUREPASSIVE, PASSIVE_VIDEO
Capture UIOurs (web, or the mobile SDKs) — camera, prompts, colour flashes, retakesYours, following the capture contractYours — any selfie (and optional clip)
Evidence keptSelfie, per-event stills, selfie clip (WebM), timeline, audit logNothing (challenge row only, deleted a day after expiry)Nothing
RetakesBuilt in, shared attempt budget, then reviewYou mint a new challengeYou call again
Resultdecision_v3.liveness_checks[], webhooks, console review queueJSON verdict in the responseJSON verdict in the response
BillingSession mint fee + one liveness runOne liveness run per verdictOne liveness run per verdict

Use a hosted session when the result backs a compliance decision, when you need evidence an operator (or auditor) can review later, or when you don't want to build and maintain camera capture for active methods — it's the only path with stills, the clip, retakes and the review queue. Use challenge / verify when you already own a native camera experience and need active liveness inline with it, and you keep your own records. Use the passive tool for a quick, stateless live-person check on a selfie you already captured — step-up at login, re-verification, or triage.

Active liveness with our capture UI (hosted session)

No new endpoints — a workflow with only the Liveness module is a liveness-only session:

  1. In the console, create a workflow and enable just Liveness, with its method set to FLASHING, 3D_ACTION or GESTURE (or PASSIVE / PASSIVE_VIDEO). Note its id (or list it with GET /v3/workflows/). Face match isn't required; without an ID step there's nothing to match against.
  2. Create a session for that workflow:
curl -X POST "$TF_BASE_URL/v3/session/" \
  -H "x-api-key: $TF_API_KEY" -H "Content-Type: application/json" \
  -d '{"workflow_id":"<liveness-only workflow id>","vendor_data":"user-123","callback_url":"https://example.com/done"}'
const session = await fetch(`${process.env.TF_BASE_URL}/v3/session/`, {
  method: "POST",
  headers: { "x-api-key": process.env.TF_API_KEY!, "Content-Type": "application/json" },
  body: JSON.stringify({ workflow_id: livenessWorkflowId, vendor_data: "user-123" }),
}).then((r) => r.json());
// Send the applicant to session.url (redirect, popup, iframe or the SDKs).
session = requests.post(
    f"{BASE_URL}/v3/session/",
    headers={"x-api-key": API_KEY},
    json={"workflow_id": liveness_workflow_id, "vendor_data": "user-123"},
    timeout=15,
).json()
redirect_to(session["url"])
  1. Send the applicant to the returned url (see Create a session and the SDKs). The flow issues the challenge, drives the camera and flashes or prompts, and retries capture problems for you.
  2. On the callback_url redirect or the session webhook, read GET /v3/session/{id}/decision/decision_v3.liveness_checks:
{
  "node_id": "LIVENESS",
  "status": "approved",
  "score": 91.2,
  "warnings": [],
  "data": {
    "method": "3D_ACTION",
    "selfie_url": "https://…/v3/media/kyc/faces/…/selfie.jpg",
    "per_action_pass": { "turn_left": true, "look_up": true, "smile": true },
    "event_stills": [
      { "label": "Turn your head to the left", "url": "https://…/v3/media/kyc/faces/…/liveness-turn-your-head-to-the-left.jpg" }
    ]
  }
}

Every liveness event (each colour flash, head action or gesture) has a still under event_stills[].url. When the method records a selfie clip (PASSIVE_VIDEO), video_url links the WebM once video_status is ready (null while it's pending). Media URLs are fetched with your API key via GET /v3/media/….

FAQ