Designing a KYC and Identity Verification Pipeline: Document Processing, Liveness Detection, and Risk Scoring at Scale
KYC pipelines fail in predictable ways: manual review bottlenecks, inconsistent risk decisions, PII leaking through logging, and async job state that diverges from webhook status. This guide covers the full architecture of a production-grade identity verification system, from document OCR and liveness detection to risk scoring, PII encryption, and the build-vs-buy tradeoff with providers like Jumio, Onfido, and Persona.
Every FinTech product that touches money eventually has to answer the same question: who is this person, and should we let them transact? KYC, Know Your Customer, is the regulatory answer. It is also one of the most underestimated engineering problems in the regulated product space.
The naive implementation is: integrate a third-party SDK, collect a selfie and a document photo, get a pass/fail response, and gate account creation. That works for an MVP demo. In production you discover the gaps quickly. Third-party vendors return async results via webhook minutes or hours after submission. Your database state diverges from the vendor state when webhooks fail silently. Users who fail automated review need a manual review path with its own state machine. PII from OCR is flowing through your logging infrastructure. Your fraud team wants a risk score, not just a binary result. Regulators want an audit trail of every decision with the evidence that drove it.
This article covers the full architecture: document ingestion and OCR, liveness detection, identity matching and risk scoring, PII handling, async processing with status webhooks, and the build-vs-buy decision for the verification layer itself.
The KYC Pipeline in Layers
Before the code, a mental model. A KYC pipeline has five distinct layers, and each one has different latency, reliability, and data sensitivity requirements.
User submission
│
▼
[ 1. Ingestion ] — Receive documents and biometrics, store encrypted
│
▼
[ 2. Extraction ] — OCR name/DOB/document number, detect liveness
│
▼
[ 3. Identity Matching ] — Compare extracted data against reference sources
│
▼
[ 4. Risk Scoring ] — Combine signals into an approval/review/reject decision
│
▼
[ 5. Decision + Audit ] — Persist decision with evidence, notify caller via webhook
Each layer is independently fallible. Document extraction can succeed while identity matching times out. Risk scoring can complete while the webhook delivery fails. The job of the pipeline is to move state forward reliably while keeping the caller informed and the evidence record intact.
Document Ingestion and Encrypted Storage
The first thing to get right is that document images are PII from the moment they land on your server. An ID card contains name, date of birth, address, and document number. A selfie is biometric data. Both classes carry regulatory obligations in virtually every jurisdiction where you operate.
The ingestion step has one job: receive the file, encrypt it, write the encrypted blob to object storage, and write a metadata record pointing to it. Nothing else should process raw document bytes.
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { KMSClient, GenerateDataKeyCommand, EncryptCommand } from "@aws-sdk/client-kms";
import { randomUUID } from "crypto";
interface DocumentIngestionResult {
documentId: string;
storageKey: string;
encryptionKeyId: string;
contentType: string;
byteSize: number;
uploadedAt: Date;
}
async function ingestDocument(
fileBuffer: Buffer,
contentType: string,
kmsKeyArn: string,
s3Bucket: string
): Promise<DocumentIngestionResult> {
const kms = new KMSClient({});
const s3 = new S3Client({});
// Generate a data key for envelope encryption
const dataKeyResponse = await kms.send(
new GenerateDataKeyCommand({
KeyId: kmsKeyArn,
KeySpec: "AES_256",
})
);
const plainDataKey = dataKeyResponse.Plaintext!;
const encryptedDataKey = dataKeyResponse.CiphertextBlob!;
// Encrypt the document using the data key (AES-256-GCM in practice)
const encryptedBuffer = encryptWithDataKey(fileBuffer, Buffer.from(plainDataKey));
// Zero the plaintext key from memory immediately
plainDataKey.fill(0);
const documentId = randomUUID();
const storageKey = `documents/${documentId}/raw`;
await s3.send(
new PutObjectCommand({
Bucket: s3Bucket,
Key: storageKey,
Body: encryptedBuffer,
ContentType: "application/octet-stream",
Metadata: {
"x-encrypted-data-key": Buffer.from(encryptedDataKey).toString("base64"),
"x-kms-key-id": kmsKeyArn,
},
ServerSideEncryption: "aws:kms",
})
);
return {
documentId,
storageKey,
encryptionKeyId: kmsKeyArn,
contentType,
byteSize: fileBuffer.byteLength,
uploadedAt: new Date(),
};
}
Two design decisions worth calling out. First, envelope encryption: the document is encrypted with a per-document AES-256 key, which is itself encrypted by a KMS master key. This means rotating the master key does not require re-encrypting every document, and revoking access to a user’s documents can be done by destroying only their data key. Second, the plaintext data key is zeroed in memory immediately after use. TypeScript does not guarantee garbage collection timing, so explicit zeroing is the only reliable approach.
The raw document bytes should never appear in application logs. Add a structured logging middleware that redacts any field matching document content type patterns before the log event is emitted.
OCR and Document Extraction
Once the document is stored, extraction runs as an async job. The job retrieves and decrypts the document, calls an OCR service, and persists the extracted fields.
For in-house OCR, AWS Textract or Google Document AI give you layout-aware field extraction with reasonable accuracy on government documents. For production KYC at volume, purpose-built document processing APIs (the same vendors integrated at the pipeline level) return pre-structured results with MRZ parsing, document type classification, and tamper detection.
interface DocumentExtractionJob {
jobId: string;
documentId: string;
verificationSessionId: string;
status: "pending" | "processing" | "completed" | "failed";
attempts: number;
maxAttempts: number;
}
interface ExtractedDocumentFields {
documentType: "passport" | "national_id" | "drivers_license";
firstName: string;
lastName: string;
dateOfBirth: Date;
documentNumber: string;
issuingCountry: string;
expiryDate: Date;
mrzLine1?: string;
mrzLine2?: string;
extractionConfidence: number; // 0.0 to 1.0
tamperDetected: boolean;
expiryStatus: "valid" | "expired" | "expiring_soon";
}
async function runDocumentExtraction(
job: DocumentExtractionJob
): Promise<ExtractedDocumentFields> {
const document = await retrieveAndDecryptDocument(job.documentId);
// Call OCR/extraction service
const raw = await ocrProvider.analyzeDocument(document.buffer, {
documentType: "identity",
returnMRZ: true,
detectTampering: true,
});
const fields: ExtractedDocumentFields = {
documentType: normalizeDocumentType(raw.documentType),
firstName: sanitizeName(raw.firstName),
lastName: sanitizeName(raw.lastName),
dateOfBirth: parseDate(raw.dateOfBirth),
documentNumber: raw.documentNumber.toUpperCase().replace(/\s/g, ""),
issuingCountry: raw.issuingCountry,
expiryDate: parseDate(raw.expiryDate),
mrzLine1: raw.mrz?.line1,
mrzLine2: raw.mrz?.line2,
extractionConfidence: raw.confidence,
tamperDetected: raw.tamperSignals?.length > 0,
expiryStatus: getExpiryStatus(parseDate(raw.expiryDate)),
};
// Store extracted fields encrypted at field level, not as raw JSON
await persistExtractedFields(job.verificationSessionId, fields);
return fields;
}
Low confidence scores (below roughly 0.75) should route to manual review rather than proceeding through automated matching. Tamper detection signals should always require manual review regardless of confidence, and the decision rationale should be preserved in the audit record.
The extracted fields themselves are PII and should be encrypted at the field level in your database. Using a column encryption library (like prisma-field-encryption or a custom KMS-backed approach) means that even direct database access does not expose plaintext PII.
Liveness Detection and Selfie Matching
Liveness detection prevents a static photo from passing the biometric check. The problem it solves is straightforward: someone can hold up a printed photo of their victim’s face. A liveness check requires the user to perform a passive or active challenge that a printed photo cannot satisfy.
There are two approaches in practice. Passive liveness uses a depth model or texture analysis applied to a single selfie frame. Active liveness prompts the user for a gesture (blink, turn head) and validates the motion sequence. Passive is lower friction; active is higher assurance.
Building either from scratch requires a trained model and continuous evaluation against new spoofing techniques. The maintenance burden is not trivial. The practical decision for most FinTech startups is to use a provider for the liveness component specifically, even if you own other parts of the pipeline.
For the architecture, liveness runs as a separate job from document extraction, and the two results are correlated by the verification session ID before the matching step begins.
interface LivenessCheckResult {
sessionId: string;
livenessScore: number; // 0.0 to 1.0
livenessVerdict: "live" | "spoof_suspected" | "inconclusive";
faceSimilarityScore?: number; // compared to document photo, 0.0 to 1.0
faceSimilarityVerdict?: "match" | "no_match" | "inconclusive";
processingTimeMs: number;
providerReference?: string;
}
async function correlateLivenessWithDocument(
sessionId: string
): Promise<{ livenessResult: LivenessCheckResult; extractionResult: ExtractedDocumentFields }> {
const [liveness, extraction] = await Promise.all([
getLivenessResult(sessionId),
getExtractionResult(sessionId),
]);
if (!liveness || !extraction) {
throw new Error(`Incomplete pipeline results for session ${sessionId}`);
}
return { livenessResult: liveness, extractionResult: extraction };
}
The face similarity score comparing the selfie against the document photo is a separate signal from liveness. A user can be live but their face does not match the document. Both signals feed into the risk scoring layer independently.
Risk Scoring Model
The risk score aggregates signals from document extraction, liveness, identity matching, and contextual data into a single decision. This is where the architecture choices become consequential for compliance: the model must be explainable, not just accurate.
interface VerificationSignals {
// Document signals
documentExtractionConfidence: number;
documentTamperDetected: boolean;
documentExpired: boolean;
documentTypeSupported: boolean;
// Liveness signals
livenessScore: number;
livenessVerdict: "live" | "spoof_suspected" | "inconclusive";
faceSimilarityScore: number;
// Identity matching signals
nameMatchScore: number; // fuzzy match against reference data
dobMatch: boolean;
documentNumberSanctionsHit: boolean;
pepListHit: boolean; // politically exposed persons
adverseMediaSignals: number; // count of negative hits
// Contextual signals
ipRiskScore: number;
deviceFingerprintRisk: "low" | "medium" | "high";
submissionVelocity: number; // attempts in last 24h
countryRiskTier: 1 | 2 | 3; // FATF risk classification
}
type VerificationDecision = "approved" | "manual_review" | "rejected";
interface ScoredDecision {
decision: VerificationDecision;
riskScore: number; // 0 to 100
decisionFactors: DecisionFactor[];
requiresManualReview: boolean;
autoRejectReason?: string;
}
interface DecisionFactor {
signal: string;
value: number | boolean | string;
contribution: number; // positive = risk-increasing
threshold?: number;
}
function scoreVerification(signals: VerificationSignals): ScoredDecision {
const factors: DecisionFactor[] = [];
let riskScore = 0;
// Hard rejection conditions — these override the score
if (signals.documentNumberSanctionsHit) {
return {
decision: "rejected",
riskScore: 100,
decisionFactors: [{ signal: "sanctions_hit", value: true, contribution: 100 }],
requiresManualReview: false,
autoRejectReason: "sanctions_match",
};
}
if (signals.documentTamperDetected) {
return {
decision: "rejected",
riskScore: 100,
decisionFactors: [{ signal: "tamper_detected", value: true, contribution: 100 }],
requiresManualReview: false,
autoRejectReason: "document_tamper",
};
}
// Weighted risk contribution
if (signals.livenessVerdict === "spoof_suspected") {
riskScore += 40;
factors.push({ signal: "liveness_spoof", value: signals.livenessScore, contribution: 40, threshold: 0.7 });
} else if (signals.livenessVerdict === "inconclusive") {
riskScore += 15;
factors.push({ signal: "liveness_inconclusive", value: signals.livenessScore, contribution: 15 });
}
if (signals.faceSimilarityScore < 0.6) {
riskScore += 35;
factors.push({ signal: "face_mismatch", value: signals.faceSimilarityScore, contribution: 35, threshold: 0.75 });
}
if (signals.pepListHit) {
riskScore += 20;
factors.push({ signal: "pep_hit", value: true, contribution: 20 });
}
if (signals.countryRiskTier === 3) {
riskScore += 15;
factors.push({ signal: "high_risk_country", value: signals.countryRiskTier, contribution: 15 });
}
if (signals.submissionVelocity > 3) {
riskScore += 10;
factors.push({ signal: "high_velocity", value: signals.submissionVelocity, contribution: 10 });
}
const decision: VerificationDecision =
riskScore >= 60 ? "rejected" : riskScore >= 30 ? "manual_review" : "approved";
return {
decision,
riskScore: Math.min(riskScore, 100),
decisionFactors: factors,
requiresManualReview: decision === "manual_review",
};
}
The decision factors array is what makes this auditable. Every regulatory regime requires that automated decisions be explainable. When a compliance officer or regulator asks why a specific user was rejected, the decision record must contain a human-readable explanation tied to specific signals, not just a score.
Async Processing and Webhook Delivery
KYC verification is inherently async. Third-party providers do not return results synchronously on the submission call. Even when running your own pipeline, document extraction and liveness processing each take seconds to minutes depending on load.
The session state machine tracks where the pipeline is at any given moment.
type SessionStatus =
| "created"
| "documents_submitted"
| "extraction_queued"
| "liveness_pending"
| "matching_in_progress"
| "risk_scoring"
| "manual_review"
| "approved"
| "rejected"
| "expired";
interface VerificationSession {
sessionId: string;
userId: string;
status: SessionStatus;
submittedAt: Date;
completedAt?: Date;
expiresAt: Date;
decision?: ScoredDecision;
webhookDeliveries: WebhookDelivery[];
}
interface WebhookDelivery {
webhookId: string;
url: string;
event: "verification.completed" | "verification.requires_review" | "verification.failed";
payload: string; // JSON, no PII in the payload
attemptCount: number;
lastAttemptAt?: Date;
deliveredAt?: Date;
lastStatusCode?: number;
}
The webhook payload should never contain PII. It should contain the session ID, the decision, the risk score, and a timestamp. Your customer’s server calls back to your API with the session ID to retrieve full results in an authenticated context. This keeps PII out of webhook logs on both sides.
Webhook delivery uses exponential backoff with a dead-letter path for persistently failing endpoints.
const RETRY_DELAYS_MS = [1000, 5000, 30000, 120000, 600000]; // 1s, 5s, 30s, 2m, 10m
async function attemptWebhookDelivery(delivery: WebhookDelivery): Promise<void> {
const signature = signPayload(delivery.payload, process.env.WEBHOOK_SECRET!);
try {
const response = await fetch(delivery.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Signature-256": signature,
"X-Webhook-ID": delivery.webhookId,
},
body: delivery.payload,
signal: AbortSignal.timeout(10_000),
});
if (response.ok) {
await markWebhookDelivered(delivery.webhookId);
return;
}
// Non-2xx counts as failure, schedule retry
throw new Error(`Non-2xx response: ${response.status}`);
} catch {
if (delivery.attemptCount >= RETRY_DELAYS_MS.length) {
await moveWebhookToDeadLetter(delivery.webhookId);
return;
}
const delayMs = RETRY_DELAYS_MS[delivery.attemptCount];
await scheduleWebhookRetry(delivery.webhookId, delayMs);
}
}
PII Handling: Encryption In Transit and At Rest
The fields that require special treatment in a KYC pipeline are: document images, selfie images, OCR-extracted fields (name, DOB, document number, address), and any face vectors or embeddings generated during liveness processing.
In transit: TLS 1.3 minimum between all service boundaries, including internal services. Internal service calls are a common PII leak vector that gets missed in threat models.
At rest: envelope encryption at the storage layer (KMS-managed data keys per document) plus field-level encryption for PII columns in the relational database. The two are complementary: storage encryption protects against disk-level compromise, field encryption protects against SQL injection or misconfigured database access controls.
Field-level encryption example for a PII record:
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
// KMS wraps the per-record key; this is the decrypt path
async function decryptPiiField(
encryptedValue: string,
encryptedDataKey: string,
kmsKeyArn: string
): Promise<string> {
const plainDataKey = await kms.decrypt({
CiphertextBlob: Buffer.from(encryptedDataKey, "base64"),
KeyId: kmsKeyArn,
});
const [ivHex, cipherHex] = encryptedValue.split(":");
const iv = Buffer.from(ivHex, "hex");
const cipher = Buffer.from(cipherHex, "hex");
const decipher = createDecipheriv("aes-256-gcm", plainDataKey.Plaintext!, iv);
const decrypted = Buffer.concat([decipher.update(cipher), decipher.final()]);
plainDataKey.Plaintext!.fill(0);
return decrypted.toString("utf8");
}
Retention and deletion are the other half of PII compliance. Most jurisdictions require you to delete identity documents after a defined period (commonly 5-7 years, or immediately after a failed verification depending on the regulation). The verificationSession record should have a scheduledDeletionAt timestamp set at creation time, and a background job should handle secure deletion including overwriting S3 objects before deletion.
Build vs. Buy: KYC Providers
The question FinTech startups face is not whether to use a KYC provider but which layers to own.
| Layer | Build | Integrate (Provider) | When to upgrade |
|---|---|---|---|
| Document OCR | High accuracy possible with Textract/DocAI; requires ongoing tuning for new document types | Jumio, Onfido, Persona cover 190+ countries out of the box | Start integrated; consider building if you’re processing >100K verifications/month and provider cost is material |
| Liveness detection | Requires trained model; spoofing techniques evolve; ongoing maintenance | Providers maintain anti-spoofing models continuously | Almost never worth building for FinTech; biometric liability and model drift are costly |
| Identity matching | Watchlist and PEP databases (Refinitiv, Dow Jones) are licensed separately | Persona and Onfido bundle matching against major watchlists | Build your own matching layer on top of licensed data if you need custom risk logic |
| Risk scoring | Fully custom; you own the model and the decision factors | Providers return a binary pass/fail with limited signal exposure | Build your own scoring layer once you have enough volume to calibrate; provider verdicts alone are not enough for a fraud team |
| Webhook and session management | You must own this regardless | Providers deliver their own callbacks; you still need to map to your session model | Always build; provider webhook schemas differ and you need your own state machine |
| Audit trail | You must own this regardless | Providers retain their own logs; not sufficient for your compliance obligations | Always build; you need full decision provenance in your own systems |
The practical architecture for a seed-to-Series A FinTech startup: use a provider (Persona is developer-friendly; Onfido has strong global document coverage; Jumio is preferred in enterprise financial services) for document extraction and liveness. Build your own session state machine, risk scoring layer, webhook delivery, and audit trail on top of provider results. As volume grows and provider costs become material, extracting the document processing layer becomes feasible, but the liveness model should remain integrated unless you have dedicated ML infrastructure.
Production Considerations
Idempotency on submission. Users retry submissions when they don’t see confirmation. Deduplicate on a client-generated idempotency key tied to the session; return the existing session rather than creating a new one. Track submission velocity per user and per device fingerprint to detect resubmission abuse.
Session expiry. KYC sessions should have a hard expiry window (commonly 15-30 minutes for document submission). After expiry, the user restarts. This prevents stale sessions from accumulating in your database and limits the window during which submitted documents need to be retained.
Manual review queue. Every KYC pipeline generates a manual review backlog. The review interface needs to be audited: every decision made by a human reviewer should be logged with the reviewer identity, timestamp, decision, and rationale. Reviewers should not be able to see the full PII record unless their role explicitly requires it (principle of least privilege applied to internal tooling).
Regulatory jurisdiction mapping. The acceptable document types, required fields, and retention requirements differ by jurisdiction. A US FinTech accepting EU users has GDPR obligations on top of FinCEN requirements. Model jurisdiction into your session data model from day one; retrofitting it after launch is painful.
Provider fallback. If your primary KYC provider has an outage, you need either a secondary provider or a queuing mechanism to hold submissions for later processing. Silent failures (provider returns 200 but processes nothing) are the most dangerous class; instrument verification completion rate as a health metric.
Closing
KYC is one of the few places in product engineering where both regulatory compliance and fraud pressure converge on the same system at the same time. The architecture has to satisfy auditors, resist spoofing, protect PII across its full lifecycle, and deliver decisions fast enough that legitimate users don’t abandon the onboarding flow.
The common failure mode is treating it as a webhook integration problem rather than a pipeline architecture problem. The webhook is the last step. Getting there reliably, with every decision explained and every byte of PII accounted for, is what the pipeline is actually for.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.