Identity Verification Patterns for Fintech
Proven architectural patterns for implementing identity verification in fintech applications, from layered checks to fallback strategies and confidence scoring.
Identity verification in fintech is not a single check but a layered process. Customers present documents, answer knowledge-based questions, submit biometric data, and consent to database lookups. Each layer adds confidence that the person on the other end is who they claim to be. The challenge for engineering teams is orchestrating these layers into a cohesive system that is both secure and fast.
At Klivvr, Oasis implements identity verification as a composable set of patterns rather than a monolithic procedure. This article describes the patterns we rely on most heavily: the layered verification strategy, the confidence accumulator, the fallback chain, and the provider abstraction layer. These patterns are language-agnostic in concept but are illustrated here with TypeScript, the language Oasis is written in.
The Layered Verification Strategy
The most fundamental pattern in identity verification is layering. No single check is sufficient. A passport photograph can be forged. A database lookup can return stale data. A knowledge-based question can be answered by someone who has stolen the customer's personal information. But the combination of multiple independent checks raises the bar for fraud dramatically.
We model each layer as an independent verification method with a defined confidence contribution:
interface VerificationMethod {
name: string;
maxConfidence: number;
execute(context: VerificationContext): Promise<MethodResult>;
}
interface MethodResult {
confidence: number;
passed: boolean;
evidence: Record<string, unknown>;
failureReason?: string;
}
interface VerificationContext {
customerId: string;
providedIdentity: ProvidedIdentity;
documents: SubmittedDocument[];
sessionMetadata: SessionMetadata;
}
interface ProvidedIdentity {
fullName: string;
dateOfBirth: string;
nationality: string;
address: Address;
taxId?: string;
}
class LayeredVerifier {
private methods: VerificationMethod[] = [];
private requiredConfidence: number;
constructor(requiredConfidence: number) {
this.requiredConfidence = requiredConfidence;
}
addMethod(method: VerificationMethod): void {
this.methods.push(method);
}
async verify(context: VerificationContext): Promise<LayeredResult> {
let accumulatedConfidence = 0;
const results: Map<string, MethodResult> = new Map();
for (const method of this.methods) {
const result = await method.execute(context);
results.set(method.name, result);
if (result.passed) {
accumulatedConfidence += result.confidence;
}
if (accumulatedConfidence >= this.requiredConfidence) {
return {
verified: true,
confidence: accumulatedConfidence,
methodResults: Object.fromEntries(results),
shortCircuited: true,
};
}
}
return {
verified: accumulatedConfidence >= this.requiredConfidence,
confidence: accumulatedConfidence,
methodResults: Object.fromEntries(results),
shortCircuited: false,
};
}
}The layered verifier executes methods in sequence and accumulates confidence. Once the required threshold is met, it short-circuits and returns a positive result without running remaining methods. This is both an optimization (avoiding unnecessary external API calls) and a user experience improvement (faster approvals for customers with strong early signals).
The Confidence Accumulator Pattern
Raw binary pass/fail checks throw away valuable information. A document check that returns 95% confidence is materially different from one that returns 60%, even if both technically "pass." The confidence accumulator pattern preserves this nuance.
interface ConfidenceSignal {
source: string;
rawScore: number;
weight: number;
category: "document" | "biometric" | "database" | "behavioral";
}
class ConfidenceAccumulator {
private signals: ConfidenceSignal[] = [];
addSignal(signal: ConfidenceSignal): void {
this.signals.push(signal);
}
computeOverallConfidence(): ConfidenceReport {
const categoryScores = new Map<string, number[]>();
for (const signal of this.signals) {
const existing = categoryScores.get(signal.category) || [];
existing.push(signal.rawScore * signal.weight);
categoryScores.set(signal.category, existing);
}
const categoryAverages: Record<string, number> = {};
let totalWeightedScore = 0;
let totalWeight = 0;
for (const [category, scores] of categoryScores) {
const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
categoryAverages[category] = avg;
totalWeightedScore += avg;
totalWeight += 1;
}
const overallScore = totalWeight > 0 ? totalWeightedScore / totalWeight : 0;
return {
overallConfidence: Math.min(overallScore, 100),
categoryBreakdown: categoryAverages,
signalCount: this.signals.length,
weakestCategory: this.findWeakestCategory(categoryAverages),
};
}
private findWeakestCategory(
averages: Record<string, number>
): string | null {
let weakest: string | null = null;
let lowestScore = Infinity;
for (const [category, score] of Object.entries(averages)) {
if (score < lowestScore) {
lowestScore = score;
weakest = category;
}
}
return weakest;
}
}The accumulator collects signals from every verification method, weights them, and produces both an overall confidence score and a per-category breakdown. The per-category breakdown is especially useful for deciding what additional verification to request. If the weakest category is "biometric," the system can prompt the customer for a selfie check rather than requesting yet another document.
The Provider Abstraction Layer
Fintech companies rarely rely on a single identity verification vendor. Vendors have different geographic coverage, different strengths (one might excel at passport verification while another handles utility bills better), and different reliability profiles. The provider abstraction layer decouples Oasis from any single vendor.
interface IdentityProvider {
name: string;
supportedDocumentTypes: DocumentType[];
supportedCountries: string[];
verifyDocument(
document: SubmittedDocument,
identity: ProvidedIdentity
): Promise<ProviderResult>;
checkIdentity(identity: ProvidedIdentity): Promise<ProviderResult>;
}
interface ProviderResult {
success: boolean;
confidence: number;
rawResponse: unknown;
normalizedData: NormalizedVerificationData;
}
interface NormalizedVerificationData {
nameMatch: boolean;
dobMatch: boolean;
addressMatch: boolean;
documentAuthentic: boolean;
additionalFlags: string[];
}
class ProviderRegistry {
private providers: IdentityProvider[] = [];
register(provider: IdentityProvider): void {
this.providers.push(provider);
}
findBestProvider(
documentType: DocumentType,
country: string
): IdentityProvider | null {
return (
this.providers.find(
(p) =>
p.supportedDocumentTypes.includes(documentType) &&
p.supportedCountries.includes(country)
) || null
);
}
findAllProviders(
documentType: DocumentType,
country: string
): IdentityProvider[] {
return this.providers.filter(
(p) =>
p.supportedDocumentTypes.includes(documentType) &&
p.supportedCountries.includes(country)
);
}
}Each provider implements the same interface and returns normalized data. The normalization step is critical. Raw responses from vendors differ wildly in structure, field naming, and semantics. By normalizing at the adapter boundary, the rest of Oasis operates on a consistent data model regardless of which provider produced the result.
The Fallback Chain Pattern
External identity verification providers are not 100% reliable. Networks fail, APIs return errors, and rate limits get hit. The fallback chain ensures that a provider outage does not block customer onboarding.
class FallbackChain {
private providers: IdentityProvider[];
private logger: Logger;
constructor(providers: IdentityProvider[], logger: Logger) {
this.providers = providers;
this.logger = logger;
}
async verifyWithFallback(
document: SubmittedDocument,
identity: ProvidedIdentity
): Promise<ProviderResult> {
const errors: Array<{ provider: string; error: Error }> = [];
for (const provider of this.providers) {
try {
const result = await this.withTimeout(
provider.verifyDocument(document, identity),
10_000
);
this.logger.info("Verification succeeded", {
provider: provider.name,
confidence: result.confidence,
attemptsBeforeSuccess: errors.length,
});
return result;
} catch (error) {
this.logger.warn("Provider failed, trying next", {
provider: provider.name,
error: (error as Error).message,
});
errors.push({ provider: provider.name, error: error as Error });
}
}
throw new AllProvidersFailedError(errors);
}
private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
),
]);
}
}The chain tries providers in priority order, falling back to the next one if the current provider fails or times out. The timeout is essential: a provider that hangs for 30 seconds is worse than one that returns an error immediately, because the customer is waiting. We set aggressive timeouts (10 seconds) and would rather fall back to a secondary provider than keep the customer staring at a loading spinner.
Behavioral Signals and Device Fingerprinting
Beyond documents and database lookups, behavioral signals provide a valuable layer of identity assurance. How a customer interacts with the onboarding flow reveals information that is difficult for a fraudster to replicate.
interface BehavioralSignals {
sessionDuration: number;
formCompletionTime: number;
keystrokeDynamics: KeystrokePattern;
mouseMovementPattern: MovementPattern;
deviceFingerprint: DeviceFingerprint;
ipGeolocation: GeoLocation;
}
interface DeviceFingerprint {
userAgent: string;
screenResolution: string;
timezone: string;
language: string;
canvasHash: string;
webglHash: string;
}
class BehavioralAnalyzer implements VerificationMethod {
name = "behavioral-analysis";
maxConfidence = 15;
async execute(context: VerificationContext): Promise<MethodResult> {
const signals = context.sessionMetadata.behavioralSignals;
const riskFactors: string[] = [];
let score = 100;
if (signals.formCompletionTime < 5000) {
riskFactors.push("Form completed suspiciously fast");
score -= 40;
}
if (this.isKnownDataCenter(signals.ipGeolocation)) {
riskFactors.push("Connection from known data center IP");
score -= 30;
}
if (signals.deviceFingerprint.timezone !== signals.ipGeolocation.timezone) {
riskFactors.push("Device timezone does not match IP geolocation");
score -= 20;
}
const confidence = Math.max(0, (score / 100) * this.maxConfidence);
return {
confidence,
passed: score > 40,
evidence: { riskFactors, rawScore: score },
failureReason: score <= 40 ? "Behavioral analysis flagged suspicious activity" : undefined,
};
}
private isKnownDataCenter(geo: GeoLocation): boolean {
const dataCenterASNs = new Set(["AS14061", "AS16509", "AS15169"]);
return dataCenterASNs.has(geo.asn);
}
}Behavioral analysis is not a primary verification method. It cannot confirm identity on its own. But it excels at catching automated fraud. A bot that fills out a form in two seconds, connects from a cloud provider IP, and presents a device fingerprint with mismatched timezone data is almost certainly not a legitimate customer. These signals contribute to the overall confidence score and can tip borderline cases toward manual review.
Conclusion
Identity verification in fintech is a layered, multi-signal problem. No single check is sufficient, and no single vendor is reliable enough to depend on exclusively. The patterns described here, layered verification, confidence accumulation, provider abstraction, fallback chains, and behavioral analysis, form a framework that is both resilient and adaptable.
The key insight is that identity verification is not a binary question. It is a spectrum of confidence. By accumulating confidence from independent sources and making decisions based on thresholds rather than hard gates, you build a system that approves legitimate customers quickly while catching fraud effectively. Oasis applies these patterns to process thousands of identity verifications daily, and the composable architecture means we can add new verification methods, integrate new providers, and adjust thresholds without rewriting the core system.
Related Articles
Improving Customer Conversion Through Better Onboarding
Data-driven strategies for improving customer conversion rates during fintech onboarding, from A/B testing frameworks to personalization and real-time optimization.
Integrating Third-Party Verification APIs
Practical strategies for integrating third-party identity verification APIs, covering adapter patterns, error handling, rate limiting, and provider management in TypeScript.
Data Privacy in Customer Onboarding
Strategies for protecting customer data during the onboarding process, covering data minimization, encryption, consent management, and regulatory compliance.