Utkrushta · Engineering Design Doc · For team review

Rev 2 · signals-only · own-domain iframe

Candidate Identity Verification & Fraud Signals

Make a candidate's identity continuous — the person on the resume, the person who sits the assessment, and the person who joins should be the same human — and give recruiters the signals to see it.

Vendor: Didit (inline iframe) Model: Pure signals · no gate Volume: ~500/mo → ₹0 Effort: ~16–24 eng-days
§1

Overview — what we're building, and where it's going

The problem. Hiring is now remote and global, and the weakest link is identity continuity. A résumé can be real, a background check can pass, and the interview can go well — yet the person who actually sits our assessment (and later joins) may not be the same human. Proxy test-takers, credential-sharing, and AI-era fraud (deepfakes, synthetic "candidates") make this a live risk: Gartner projects 1 in 4 candidate profiles will be fake by 2028. Our own candidates now span four continents, which multiplies both the exposure and the legal surface.

What this is — and isn't. This is not a background-verification product, and not a deepfake-detection lab. It's an identity-continuity + fraud-signals layer built into our assessment runtime: capture a verified identity at the moment of the test, surface integrity signals to the recruiter, and (next) re-verify that same identity at the offer→join step.

North star

Buy the commodity biometrics, build the orchestration and signal-fusion that is our IP. Inform recruiters with signals; never block a candidate.

What we accomplish

Every assessment carries an identity signal (verified? face-match? liveness? document authenticity?) plus fraud signals (VPN / datacenter IP, location mismatch, wholesale paste). The recruiter gets evidence they can act on — proxy candidates and location-maskers become visible.

Design principles

Signals, not gates — never block, never judge the candidate in-flow. One global vendor, not a per-country roster. Tier by fraud signals, never nationality. Store no raw biometrics — only vendor references + scores. Reuse existing app patterns (consent, DeviceCheck, webhooks).

Where it's going — the three-stage binding

The end-state binds one identity across the whole funnel. This doc delivers the middle and sets up the right; the far edges are deliberately deferred.

Stage 1
Résumé

the claimed person

BGV — out of scope
Stage 2
Assessment

the test-taker

This spec · Phase 1–3
Stage 3
Join

the joiner

Offer→Join tool

Later, deferred: continuous in-assessment re-verification (anti hand-off) and deepfake / camera-injection detection. We reach for a specialist vendor there only when an enterprise customer funds it.

§2

How we got here — the decision journey

This design wasn't top-down. It was shaped step by step by five founder directions. Reading the arc explains why the choices are what they are.

Step 1 · The starting question

“Do BGV services (SpringVerify, CFirst) — and Ropes.ai — actually solve this?”

AskedResearch what BGV vendors verify, and how Ropes.ai's "fake-candidate detection" works.
FoundBGV verifies documents & history, after the fact, outside the live test — it can't bind the live person. Ropes.ai isn't a deepfake engine; it's a work-simulation + behavioral signals. No vendor binds "the same human moved through résumé → test → join."
SoThat binding has to be owned by our assessment runtime — it's the gap worth building.
Step 2 · Build vs buy

“Can we build this ourselves? What does it cost?”

AskedBreak down liveness, face-match, ID checks, IP intelligence — build in-house vs integrate, with real numbers.
FoundThe biometrics are cheap to rent (~$0.001 face-match, ~$0.015 liveness). The expensive walls are certification (iBeta $50–150k), government access (Aadhaar needs AUA/KUA status we can't get), and maintained datasets (IP/VPN lists). Copy-paste + IP capture, by contrast, are cheap to build.
SoBuy the commodity primitives, build the orchestration. Build IP intel + paste signals; rent identity + liveness.
Step 3 · The global reframe

“Our candidates are on 4 continents — we can't keep a roster of vendors. Can we tier by risk?”

AskedOne global approach, not per-country. Proposed heavier checks for high-risk countries, lighter for the EU.
FoundOne global aggregator covers 190+ countries (most have no queryable government ID DB anyway — India/Brazil are the exceptions). But tiering by nationality is a legal landmine (protected characteristics + NIST-documented disparate error rates), and "lighter verification" still triggers full biometric law.
SoOne global vendor (Didit, with native India Aadhaar). Tier by objective fraud signals, never nationality. Apply the biometric-consent floor everywhere.
Step 4 · Ground it in our code

“Plan for ~500/mo. Build the spec against our actual codebase.”

AskedA grounded engineering spec — candidate app (dev) + backend (main) — including the offer→join tool.
Found500/mo fits Didit's free tier (₹0). The DeviceCheck screen + consent JSONB are the reuse points; red_flags must NOT hold identity/IP data (it auto-disqualifies candidates + blocks certificates); coding runs in remote E2B (browser can't see coding pastes).
SoInsert one step after DeviceCheck; extend the consent JSONB; add 2 purpose-built tables (never red_flags); scope paste-signals to in-browser surfaces.
Step 5 · Refine (this revision)

“Don't gate — make these signals. And can we run Didit on our own pages, not a redirect?”

AskedDrop the hard/soft gate and the "In Review" state — pure signals for the recruiter. Avoid redirecting to Didit's hosted page.
FoundDidit's inline iframe embed keeps the candidate on our domain and sends raw biometrics browser→Didit (never our servers). Signals-only is also legally safer — no gate means the "freely-given consent" burden falls away. Liveness default is passive (~0.8s, just look).
SoPure signals, no gate, no new states. Inline iframe (not redirect, not a self-built camera). This is the design in §3 onward.
§3

What changed in this revision (Rev 2)

1 · Pure signals — never a gate

Identity verification is another fraud signal shown to the recruiter, like IP intel and paste signals. The candidate always proceeds — no blocking, no candidate-facing pass/fail, no “In Review” state, no new session status.

2 · On our own domain — no redirect

Didit's inline iframe embed, not the hosted redirect. The candidate never leaves our page, and raw biometrics still go browser → Didit, never touching our servers.

3 · Show the flow & the screens

Now includes a current-vs-future flow (§5), faithful screen renderings from the real app components (§6), and inline option-pickers with our pick marked (§4).

§4

The choices — options on the table, and our pick

Every option stays visible; the highlighted card is our recommendation. Push back on any of them.

Decision A How does Didit run on our pages?
Rejected

Hosted redirect

Send the candidate to verify.didit.me. Simplest — but they leave our domain mid-assessment.

✓ Our pick

Inline iframe embed

@didit-protocol/sdk-web, embedded:true. Candidate stays on our page; biometrics go browser → Didit, never our servers.

Rejected

Build our own camera UI

Standalone API — we render the camera, then POST raw images to Didit → we become a biometrics processor (BIPA/GDPR). Avoid.

Decision B How much does the result affect the candidate?
✓ Our pick

Pure signals

Never blocks. Result → recruiter panel only. No new states, simplest, legally safest.

Not now

Soft-gate

Proceed but show a candidate-facing “under review”. Adds states + support load for no pilot benefit.

Not now

Hard-gate

Block until verified. Needs a non-biometric alternative + appeal path; highest friction & legal load.

Decision C Which liveness mode? (what the candidate physically does)
✓ Our pick

Passive (~0.8s)

Just look at the camera. One frame, no blink/turn/smile. Lowest friction; iBeta L1.

3D Flash (~1.4s)

Colored light pulses on screen, hold still. Stronger anti-spoof, still near-passive. Easy toggle later.

3D Active (~1.9s)

Turn your head + flash. Strongest, but adds an explicit action & more retries.

Liveness is a per-workflow toggle in Didit — start Passive, dial up if fraud data justifies it. Mechanic: Didit reads the camera and its anti-spoof model decides live-vs-spoof; "3D Flash" bounces colored light off the face to detect a real 3D surface vs a photo/screen. (Confirm the iBeta certificate — pages say Level 1.)

§5

The flow — what we have now vs after

Exactly one non-blocking step is inserted. Nothing else in the candidate journey changes.

Today
1
Landing

invite / resume

2
Prerequisites

tasks only

3
Device Check

camera + mic + consent

4
Cover Video

optional

5
Assessment

test / task

1
Landing

invite / resume

2
Prerequisites

tasks only

3
Device Check

camera + mic + consent

4
Identity

ID + selfie + liveness

NEW · non-blocking
5
Cover Video

optional

6
Assessment

test / task

Why here, and why non-blocking

It sits right after Device Check (which releases its camera stream at "Continue") and before proctor recording starts — so only one thing uses the camera at a time. Because it never blocks, we don't move provisioning behind it; sandbox/droplet provisioning fires as it does today while the candidate does the ~1-minute capture. The candidate advances the instant capture completes; Didit's decision arrives async and becomes a recruiter signal.

§6

Screen by screen — renderings in the real app style

Mockups reuse the app's actual tokens — brand green #1B6740, Poppins, 8px cards, the DeviceCheck skeleton. Blue-dashed = the Didit iframe.

devapp.utkrusht.ai/assessment/…/interview
Screen 1 · Identity — consent (mirrors DeviceCheck)

Verify your identity

A quick check (~1 min) — scan a government ID and take a selfie.

🪪
Government IDPassport · Driver's licence · Aadhaar · national ID
🤳
Selfie + livenessLook at the camera — under a second
Prefer not to? Continue without verification

Processed by Didit. We never store your ID images.

devapp.utkrusht.ai/assessment/…/interview
Screen 2 · Didit runs inline (our page, no redirect)

Scan your document

◱ Didit secure flow · iframe · biometrics → Didit direct
Position your ID within the frame
● capturing…

URL bar still ours. The candidate never leaves the assessment.

devapp.utkrusht.ai/assessment/…/interview
Screen 3 · Passive liveness (“just look”)

Take a selfie

◱ Didit secure flow · iframe
Look at the camera · ~0.8s · no actions needed

Passive by default. (Optional step-up: colored-flash or head-turn.)

devapp.utkrusht.ai/assessment/…/interview
Screen 4 · Captured → auto-advance

Identity captured

Continuing to your assessment…

The result is shared with the recruiter as a signal.
You are never blocked or judged here.

Static mockups for review — colors, cards, buttons, and the consent row match the live app tokens (§12).

What the recruiter sees — the signals panel

The whole point: everything above becomes informational signals in the report. No candidate is blocked; the recruiter weighs the evidence, alongside the existing score/proctoring surface.

Integrity signalscandidate · session #43762
Identity verifiedYes
Face match (ID ↔ selfie)98 / 100
LivenessPassed
DocumentPassport · India
Location vs documentMumbai, IN — consistent OK
NetworkNo VPN / proxy
Copy-paste1 wholesale-paste flag Review

Mixed states shown on purpose — signals inform, they don't auto-reject.

§7

Didit integration internals (inline iframe)

Service: new FastAPI router fastapi_service/routers/v2/identity_verifications.py.

1 · create session (server-side)

FE → Next proxy → FastAPI POST /v2/identity-verifications calls Didit POST /v3/session/ (x-api-key, server-only) with workflow_id + vendor_data=<our session_id>. Returns session_token + url. Insert an identity_verifications row PENDING; idempotent in-flight lookup protects the 500/mo quota.

2 · render iframe (browser → Didit)

DiditSdk.shared.startVerification({ url, configuration:{ embedded:true, embeddedContainerId } }). Document + selfie + liveness run in the iframe; raw biometrics never touch our servers. On didit:completed we advance the candidate — we do not wait on the decision.

3 · webhook → signal

Didit → FastAPI POST /v2/identity-verifications/webhook (public). Verify X-Signature-V2 (HMAC-SHA256; reject if |now − ts| > 300s). Idempotent upsert on provider_session_id keyed by event_id. Store a trimmed decision (scores, doc country) — no raw images, only Didit signed-URL refs. This row is the recruiter signal.

Build-time flags

Confirm iframe camera permission across target mobile browsers (Didit flags iframe camera as occasionally problematic → graceful redirect fallback). Confirm exact iBeta level (pages say L1). Package is @didit-protocol/sdk-webnot the similarly-named web3 wallet SDK.

§8

Data model — 2 new tables + 1 JSONB key, justified

For each store: what, why it's required, and which alternatives were rejected. Signals-only means the identity status is pure internal plumbing — never a candidate-facing state.

Reuse · no new table

consent JSONB

Add key identity: ISO|null

  • 1:1 with session
  • written by recordSessionConsent()
  • first-write-wins merge
New table

identity_verifications

  • verification_id, status (internal)
  • testsession_id · tasksession_id (dual FK)
  • provider_session_id, vendor_data
  • is_enrolment, reference_verification_id
  • result jsonb (trimmed, no images)
New table

session_risk_signals

  • ip_address, ip_asn, ip_country
  • is_vpn · is_proxy · is_tor · is_hosting
  • proxycheck_risk
  • captured_at_stage, raw jsonb

i · reuseBiometric consent → extend consent JSONB

Why: 1:1 with the session, needs a provable timestamp, already has a home — SessionConsent = { camera, screenshare } written by recordSessionConsent() (testSessionsOperations.ts:1088). Add identity to the type + merge line.

Rejected: a biometric_consents table (over-engineering a timestamp that already has a home); position-level consent (wrong grain).

ii · newidentity_verifications

Why a table, not columns on the session rows:

  • Two PK types — testsessions PK is varchar, task_sessions is uuid; the dual-FK report_comments table already solves this.
  • 1:N over time — enrolment + offer→join re-verify + retries.
  • Async-job semantics match extension_captures.
  • PII isolation — scoped RLS, targeted erasure, off the hot session rows.

Rejected: red_flags (see iii); task_sessions.result_analysis (OCR payload, wrong provenance); columns on both tables.

iii · newsession_risk_signals — NOT red_flags

The decisive constraint

red_flags is an active disqualification channel: competency_leaderboard_dag.py::has_disqualifying_red_flags treats any non-empty flag as disqualifying; certificate_generation_dag.py blocks certificates on it. A VPN or identity signal there would silently disqualify legit candidates + block certs.

Why a table: 1:N per session, written for every candidate at session-start. Separate = zero blast radius.

Rejected: red_flags; a single JSONB column (loses append-only + clean RLS).

§9

Phases & effort

Phase 1 · IP intelligence

3–4 eng-days

Capture X-Forwarded-For at session-start; enrich with proxycheck.io (free) + self-hosted MaxMind GeoLite2 ASN+City (geoip2 dep + GEOIP_DB_PATH already wired). Store session_risk_signals; surface the Integrity panel. Ships independently of the vendor.

Phase 2 · Copy-paste signals

3–5 eng-days

(a) Strengthen the existing OCR paste_overlap.py wholesale-paste flag. (b) Live onPaste on in-browser surfaces only (tiptap + MCQ) via trackEvent. (c) E2B coding is out of browser scope. No new-tab detection (AI allowed).

Phase 3 · Identity capture

6–9 eng-days

FE: IdentityCheck.tsx (mirror DeviceCheck) + inline @didit-protocol/sdk-web; new non-blocking step in page.tsx; extend SessionConsent + writers. BE: didit_client.py, v2 router + webhook, DAO + model, migration. No enforcement flag (signals-only).

Offer→Join tool

4–6 eng-days

See §10 — after Phase 3.

Phase 4 · Continuous re-verify

deferred

Periodic in-assessment face-presence vs mid-test hand-off.

Phase 5 · Deepfake / injection

deferred

Buy-or-defer (iProov/FaceTec class); procedural defenses only for now.

Sequence: P1 (no vendor dep) → P3 (needs Didit account) → P2 (parallel) → Offer→Join. Total ≈ 16–24 eng-days.

§10

Offer→Join selfie tool

Goal: a recruiter gives their client a single-use link; the joiner takes a selfie re-verified against the assessment-time enrolment — binding joiner == test-taker (Stage 3 of the §1 diagram).

Mechanism — Didit Biometric Authentication

Recruiter mints a single-use join_token (clone the existing recruiter-utkrusht share-report flow — UUID bearer token + OTP-gate + WhatsApp delivery). The hosted selfie page (utkrushta-assessment/src/app/verify-join/[token], public) calls Didit POST /v3/session/ with a biometric-auth workflow_id + the enrolment's portrait_image reference; Didit returns a face-match 0–100. Result lands via the same webhook into an identity_verifications row with is_enrolment=false + reference_verification_id.

Keep raw biometrics off our servers

Pull the reference portrait from Didit's signed URL at re-verify time rather than warehousing the selfie (confirm signed-URL lifetime; fallback = store the portrait). This is why the Phase-3 table carries is_enrolment + reference_verification_id + the null-session CHECK.

Open questions: joiner's own consent tick · token delivery channel · fallback when the candidate skipped identity at assessment time (no enrolment → standalone verification) · recruiter-utkrusht backend-call helper not yet fully traced.

§11

Risks (ranked)

  1. red_flags blast radius — any write of identity/IP into red_flags silently disqualifies candidates + blocks certs. Separate tables prevent this; enforce in review.
  2. Camera contention — Didit iframe + ProctorTusRecorder + DeviceCheck all want the camera; sequence identity before proctor recording (DeviceCheck releases its stream @464).
  3. iframe camera permission across mobile browsers — needs allow="camera" + permissions-policy; graceful redirect fallback where blocked.
  4. 500/mo quota — idempotent create (no double-mint); monthly counter/alert.
  5. XFF trust — capture yields the proxy IP unless the real client IP is forwarded.
  6. Webhook security — verify X-Signature-V2 on raw bytes, idempotent by event_id, 200 on unknown.
  7. Legal — biometric consent, no raw images stored, retention/erasure via the dedicated table. Signals-only lowers "freely given" risk. Illinois BIPA the top jurisdiction.
  8. Didit API drift — verify endpoints/fields/headers against current docs at build time.
§12

Critical files & verification

Verification

Compliance checklist (confirm with counsel)

Separate biometric-consent tick · signals-only (no gate → lower "freely given" risk) · DPIA before launch · store no raw biometric images (Didit references only) · targeted retention/erasure via the dedicated table · Illinois BIPA notice/retention.